├── .github └── workflows │ └── test-and-build.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── History.md ├── LICENSE ├── Makefile ├── README.md ├── acl.go ├── acl_test.go ├── audit.go ├── config.yaml ├── core.go ├── docker-start.sh ├── frontend └── index.html ├── go.mod ├── go.sum ├── main.go ├── mfa.go ├── plugins.go ├── plugins ├── auth.go ├── auth │ ├── crowd │ │ └── auth.go │ ├── google │ │ └── auth.go │ ├── ldap │ │ └── auth.go │ ├── oidc │ │ └── auth.go │ ├── simple │ │ └── auth.go │ ├── token │ │ └── auth.go │ └── yubikey │ │ └── auth.go ├── cookie.go ├── errors.go ├── mfa.go ├── mfa │ ├── duo │ │ └── mfa.go │ ├── totp │ │ └── mfa.go │ └── yubikey │ │ └── mfa.go └── register.go ├── pongo.go ├── redirect.go ├── redirect_test.go └── registry.go /.github/workflows/test-and-build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: test-and-build 4 | on: 5 | push: 6 | branches: ['*'] 7 | tags: ['v*'] 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | test-and-build: 14 | defaults: 15 | run: 16 | shell: bash 17 | 18 | container: 19 | image: luzifer/archlinux 20 | env: 21 | CGO_ENABLED: 0 22 | GOPATH: /go 23 | 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Enable custom AUR package repo 28 | run: echo -e "[luzifer]\nSigLevel = Never\nServer = https://archrepo.hub.luzifer.io/\$arch" >>/etc/pacman.conf 29 | 30 | - name: Install required packages 31 | run: | 32 | pacman -Syy --noconfirm \ 33 | awk \ 34 | curl \ 35 | diffutils \ 36 | git \ 37 | go \ 38 | golangci-lint-bin \ 39 | make \ 40 | tar \ 41 | trivy \ 42 | unzip \ 43 | which \ 44 | zip 45 | 46 | - uses: actions/checkout@v3 47 | 48 | - name: Marking workdir safe 49 | run: git config --global --add safe.directory /__w/nginx-sso/nginx-sso 50 | 51 | - name: Lint and test code 52 | run: | 53 | go test -v ./... 54 | 55 | - name: Build release 56 | run: make publish 57 | env: 58 | FORCE_SKIP_UPLOAD: 'true' 59 | MOD_MODE: readonly 60 | NO_TESTS: 'true' 61 | PACKAGES: '.' 62 | 63 | - name: Execute Trivy scan 64 | run: make trivy 65 | 66 | - name: Extract changelog 67 | run: 'awk "/^#/ && ++c==2{exit}; /^#/f" "History.md" | tail -n +2 >release_changelog.md' 68 | 69 | - name: Release 70 | uses: ncipollo/release-action@v1 71 | if: startsWith(github.ref, 'refs/tags/') 72 | with: 73 | artifacts: '.build/*' 74 | bodyFile: release_changelog.md 75 | draft: false 76 | generateReleaseNotes: false 77 | 78 | ... 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | nginx-sso 2 | test.yaml 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at help@luzifer.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | Contributions are encouraged and welcome. Thank you very much for taking your time to contribute! 4 | 5 | ## Code of Conduct 6 | This project adheres to the [Contributor Covenant code of conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the [project maintainer](mailto:help@luzifer.io). 7 | 8 | ## Developer Certificate of Origin 9 | Please also review the [Developer Certificate of Origin](https://developercertificate.org/). All commits must be signed off using `git commit -s` to indicate you've read the document and your contribution meets the criteria defined in it. 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as builder 2 | 3 | ADD . /go/src/github.com/Luzifer/nginx-sso 4 | WORKDIR /go/src/github.com/Luzifer/nginx-sso 5 | 6 | ENV CGO_ENABLED=0 7 | 8 | RUN set -ex \ 9 | && apk add --no-cache \ 10 | git \ 11 | && go install \ 12 | -ldflags "-X main.version=$(git describe --tags || git rev-parse --short HEAD || echo dev)" \ 13 | -mod=readonly 14 | 15 | 16 | FROM alpine 17 | 18 | LABEL maintainer "Knut Ahlers " 19 | 20 | RUN set -ex \ 21 | && apk --no-cache add \ 22 | bash \ 23 | ca-certificates \ 24 | dumb-init \ 25 | && mkdir /data \ 26 | && chown -R 1000:1000 /data 27 | 28 | COPY --from=builder /go/bin/nginx-sso /usr/local/bin/ 29 | COPY --from=builder /go/src/github.com/Luzifer/nginx-sso/config.yaml /usr/local/share/nginx-sso/ 30 | COPY --from=builder /go/src/github.com/Luzifer/nginx-sso/docker-start.sh /usr/local/bin/ 31 | COPY --from=builder /go/src/github.com/Luzifer/nginx-sso/frontend/* /usr/local/share/nginx-sso/frontend/ 32 | 33 | EXPOSE 8082 34 | VOLUME ["/data"] 35 | 36 | USER 1000:1000 37 | 38 | ENTRYPOINT ["/usr/local/bin/docker-start.sh"] 39 | CMD ["--"] 40 | 41 | # vim: set ft=Dockerfile: 42 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | # 0.27.4 / 2024-12-12 2 | 3 | * Update Go dependencies 4 | 5 | # 0.27.3 / 2023-12-19 6 | 7 | * Update dependencies 8 | 9 | # 0.27.2 / 2023-10-14 10 | 11 | * Update dependencies 12 | 13 | # 0.27.1 / 2023-07-29 14 | 15 | * [#79] Fix Docker image broken by user change 16 | 17 | # 0.27.0 / 2023-07-29 18 | 19 | * New Features 20 | * Add support for K8s ingress-nginx "rd" URL parameter 21 | * [#78] Allow for sprig templating in configuration file 22 | 23 | * Improvements 24 | * Move Docker image to use non-root user 25 | * Rewrite ACL logic, add support for multiple rules to be applied to the same request and therefore also denying anonymous access to certain requests while allowing it on the general site 26 | 27 | * Internal Changes 28 | * Update to Go 1.20, update dependencies 29 | * Update go-oidc to v3 30 | * [ci] Switch to Github Actions 31 | 32 | Please note this release makes changes how the ACL is applied. The test cases were (functionally) not altered, so the behavior **should** not change, though you really should test whether your rules are still working fine. 33 | 34 | Also if you're using the Docker image please note the default user in the image has changed from root (ID 0) to an unprivileged user (ID 1000). You might need to adjust your config to be read or overwrite the Docker user if you relied on it to be the root user. 35 | 36 | # 0.26.0 / 2022-12-21 37 | 38 | * Add health-endpoint, fix copy on empty dir 39 | * [#65] Provide Dockerfile for arm64v8 architecture (#66) 40 | * Update dumb-init 41 | * Switch Dockerfile to readonly modules and recent alpine 42 | * Fix: Compiler refuses to convert 0x0 to string 43 | * [ci] Fix missing utils 44 | * Remove vendored libraries 45 | 46 | # 0.25.0 / 2020-06-22 47 | 48 | * [#62] Add support for multiple domain requirements (#63) 49 | * Add cookie auth key environment variable (#59) 50 | 51 | # 0.24.1 / 2020-04-08 52 | 53 | * Lint: Fix some minor linter errors 54 | * Fix: Config loading after CookieStore init (#58) 55 | 56 | # 0.24.0 / 2020-01-13 57 | 58 | * [#50] Handle all 4xx errors as "user not found" (#52) 59 | 60 | # 0.23.0 / 2019-12-28 61 | 62 | * Allow to configure anonymous access (#48) 63 | 64 | # 0.22.0 / 2019-11-03 65 | 66 | * Switch to Go1.12+ vendoring 67 | * Fix: Broken HTML tag 68 | * Fix: Handle Unauthorized as no user found instead of generic error 69 | * Update vendored libraries 70 | 71 | # 0.21.5 / 2019-06-29 72 | 73 | * [#41] Set default cookie values in all providers (#45) 74 | 75 | # 0.21.4 / 2019-06-15 76 | 77 | * Prefer simple authenticator over LDAP (#42) 78 | 79 | # 0.21.3 / 2019-05-14 80 | 81 | * Fix: Even with offline access no refresh token is present 82 | 83 | # 0.21.2 / 2019-05-13 84 | 85 | * Fix: Google not returning refresh tokens 86 | 87 | # 0.21.1 / 2019-04-26 88 | 89 | * Fix: Use cookie for redirects after oAuth flow 90 | 91 | # 0.21.0 / 2019-04-23 92 | 93 | * [#35] Implement OpenID Connect auth provider 94 | * Fix: Only overwrite default if config is non-empty 95 | 96 | # 0.20.1 / 2019-04-22 97 | 98 | * Fix: Do not list login methods without label 99 | 100 | # 0.20.0 / 2019-04-22 101 | 102 | * Add special group for all authenticated users 103 | * Modernize login dialog 104 | 105 | # 0.19.0 / 2019-04-22 106 | 107 | * Update dependencies 108 | * Move auth plugins to own modules 109 | * Move MFA plugins to own modules 110 | * Add default page in case neither redirect was specified 111 | * Implement oAuth2 provider: Google 112 | * Prepare moving auth plugins to own modules 113 | 114 | # 0.18.0 / 2019-04-21 115 | 116 | * Add redirect on root URL to login page 117 | * Add default redirect URL for missing go-parameter 118 | 119 | # 0.17.0 / 2019-04-21 120 | 121 | * Work around missing URL parameters (#39) 122 | 123 | # 0.16.2 / 2019-04-16 124 | 125 | * Replace CDNJS as of permanent CORS failures 126 | 127 | # 0.16.1 / 2019-03-17 128 | 129 | * Fix: Do not crash main program on incompatible plugins 130 | 131 | # 0.16.0 / 2019-02-23 132 | 133 | * Enable CGO for plugin support 134 | * Add plugin support (#38) 135 | 136 | # 0.15.1 / 2019-01-17 137 | 138 | * Fix: Host already had the port attached 139 | * Fix audit logging when not using MFA (#32) 140 | 141 | # 0.15.0 / 2019-01-06 142 | 143 | * Add timestamp to audit log (#31) 144 | * Fix several linter errors 145 | 146 | # 0.14.0 / 2018-12-29 147 | 148 | * [#25] Make TOTP provider fully configurable (#29) 149 | * Move documentation to project Wiki 150 | 151 | # 0.13.0 / 2018-12-28 152 | 153 | * Add support for Duo MFA (#28) 154 | 155 | # 0.12.0 / 2018-12-24 156 | 157 | * Implement MFA verification for logins (#10) 158 | 159 | # 0.11.1 / 2018-11-18 160 | 161 | * [#19] Documentation improvements (#20) 162 | 163 | # 0.11.0 / 2018-11-17 164 | 165 | * [#17] Implement audit logging 166 | 167 | # 0.10.0 / 2018-09-24 168 | 169 | * Fix TLS dialing (#16) 170 | * Use multi-stage build to reduce image size 171 | 172 | # 0.9.0 / 2018-09-20 173 | 174 | * Implement config reload on SIGHUP (#12) 175 | 176 | # 0.8.1 / 2018-09-08 177 | 178 | * Fix: Memory leak due to http requests stored forever 179 | * Update repo-runner image 180 | 181 | # 0.8.0 / 2018-07-26 182 | 183 | * Allow searching group members by username (#9) 184 | 185 | # 0.7.1 / 2018-06-18 186 | 187 | * Fix: Ensure alias is set correctly when it is a DN 188 | 189 | # 0.7.0 / 2018-06-18 190 | 191 | * Add configurable username to LDAP auth 192 | 193 | # 0.6.0 / 2018-03-15 194 | 195 | * Add LDAP support (#3) 196 | 197 | # 0.5.0 / 2018-02-04 198 | 199 | * Implement Crowd authentication (#2) 200 | 201 | # 0.4.2 / 2018-02-04 202 | 203 | * Fix: Group assignments were not applied for Token auth 204 | 205 | # 0.4.1 / 2018-02-04 206 | 207 | * Fix: Token auth always had a logged in user 208 | 209 | # 0.4.0 / 2018-02-04 210 | 211 | * Allow grouping of tokens for simpler ACL 212 | 213 | # 0.3.0 / 2018-01-28 214 | 215 | * Document auto-renewal 216 | * Auto-Renew cookies in simple and yubikey authenticators 217 | 218 | # 0.2.0 / 2018-01-28 219 | 220 | * Add usage docs 221 | * Add basic auth to simple provider 222 | * Add dockerized version 223 | 224 | # 0.1.0 / 2018-01-28 225 | 226 | * Initial version (#1) 227 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018- Knut Ahlers 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | publish: 2 | curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh 3 | bash golang.sh 4 | 5 | # -- Vulnerability scanning -- 6 | 7 | trivy: 8 | trivy fs . \ 9 | --dependency-tree \ 10 | --exit-code 1 \ 11 | --format table \ 12 | --ignore-unfixed \ 13 | --quiet \ 14 | --scanners config,license,secret,vuln \ 15 | --severity HIGH,CRITICAL \ 16 | --skip-dirs docs 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/Luzifer/nginx-sso)](https://goreportcard.com/report/github.com/Luzifer/nginx-sso) 2 | ![](https://badges.fyi/github/license/Luzifer/nginx-sso) 3 | ![](https://badges.fyi/github/downloads/Luzifer/nginx-sso) 4 | ![](https://badges.fyi/github/latest-release/Luzifer/nginx-sso) 5 | 6 | # Luzifer / nginx-sso 7 | 8 | This program is intended to be used within the [`ngx_http_auth_request_module`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) of nginx to provide a single-sign-on for a domain using one central authentication directory. 9 | 10 | ## Documentation 11 | 12 | In order to increase readability of the documentation it has been moved to the [Github project Wiki](https://github.com/Luzifer/nginx-sso/wiki). You can find everything previously documented in the README there. 13 | -------------------------------------------------------------------------------- /acl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/Luzifer/go_helpers/v2/str" 10 | ) 11 | 12 | const ( 13 | groupAnonymous = "@_anonymous" 14 | groupAuthenticated = "@_authenticated" 15 | ) 16 | 17 | type ( 18 | acl struct { 19 | RuleSets []aclRuleSet `yaml:"rule_sets"` 20 | } 21 | 22 | aclRule struct { 23 | Field string `yaml:"field"` 24 | Invert bool `yaml:"invert"` 25 | IsPresent *bool `yaml:"present"` 26 | MatchRegex *string `yaml:"regexp"` 27 | MatchString *string `yaml:"equals"` 28 | } 29 | 30 | aclAccessResult uint 31 | 32 | aclRuleSet struct { 33 | Rules []aclRule `yaml:"rules"` 34 | 35 | Allow []string `yaml:"allow"` 36 | Deny []string `yaml:"deny"` 37 | } 38 | ) 39 | 40 | const ( 41 | accessDunno aclAccessResult = iota 42 | accessAllow 43 | accessDeny 44 | ) 45 | 46 | // --- ACL 47 | 48 | func (a acl) HasAccess(user string, groups []string, r *http.Request) bool { 49 | var ( 50 | collectionAllow = map[string]bool{} 51 | collectionDeny = map[string]bool{} 52 | ) 53 | 54 | for _, rs := range a.RuleSets { 55 | if !rs.AppliesToRequest(r) { 56 | continue 57 | } 58 | 59 | // Collect the allows from all matching rulesets 60 | for _, a := range rs.Allow { 61 | collectionAllow[a] = true 62 | } 63 | 64 | // Collect the denies from all matching rulesets 65 | for _, d := range rs.Deny { 66 | collectionDeny[d] = true 67 | } 68 | } 69 | 70 | // Form lists from the collections 71 | var allowed, denied []string 72 | 73 | for k := range collectionAllow { 74 | allowed = append(allowed, k) 75 | } 76 | for k := range collectionDeny { 77 | denied = append(denied, k) 78 | } 79 | 80 | return a.checkAccess(user, groups, allowed, denied) 81 | } 82 | 83 | func (a acl) Validate() error { 84 | for i, r := range a.RuleSets { 85 | if err := r.Validate(); err != nil { 86 | return fmt.Errorf("RuleSet on position %d is invalid: %s", i+1, err) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (a acl) checkAccess(user string, groups, allowed, denied []string) bool { 94 | if !str.StringInSlice(user, []string{"", "\x00"}) { 95 | // The user is set to a non-anon user, we add the pseudo-group 96 | // authenticated to the groups list the user has 97 | groups = append(groups, groupAuthenticated) 98 | } else { 99 | // The user did match anon, therefore we set the pseudo-group 100 | // for anonymous to be used in group matching 101 | groups = []string{groupAnonymous} 102 | } 103 | 104 | // Quoting the documentation here: 105 | // "There is a simple logic: Users before groups, denies before allows." 106 | 107 | // Lets check the user 108 | if str.StringInSlice(user, denied) { 109 | // Explicit deny on the user, they're out! 110 | return false 111 | } 112 | 113 | if str.StringInSlice(user, allowed) { 114 | // Explicit allow on the user, they're in! 115 | return true 116 | } 117 | 118 | // The user yielded no result, lets check the groups 119 | for _, group := range groups { 120 | if str.StringInSlice(a.fixGroupName(group), denied) { 121 | // The group is denied access 122 | return false 123 | } 124 | 125 | if str.StringInSlice(a.fixGroupName(group), allowed) { 126 | // The group is allowed access 127 | return true 128 | } 129 | } 130 | 131 | // We found no match for the user and/or group. Last chance is 132 | // no ruleset denied anonymous access and at least one ruleset 133 | // enabled anonymous access 134 | if !str.StringInSlice(groupAnonymous, denied) && str.StringInSlice(groupAnonymous, allowed) { 135 | return true 136 | } 137 | 138 | // We found neither a user nor a group with access or deny config 139 | // so we fall back to the default: No access. 140 | return false 141 | } 142 | 143 | func (acl) fixGroupName(group string) string { 144 | return "@" + strings.TrimLeft(group, "@") 145 | } 146 | 147 | // --- ACL Rule 148 | 149 | // AppliesToFields checks whether the given rule conditions matches 150 | // the given fields 151 | func (a aclRule) AppliesToFields(fields map[string]string) bool { 152 | var field, value string 153 | 154 | for f, v := range fields { 155 | if strings.ToLower(a.Field) == f { 156 | field = f 157 | value = v 158 | break 159 | } 160 | } 161 | 162 | if a.IsPresent != nil { 163 | if !a.Invert && *a.IsPresent && field == "" { 164 | // Field is expected to be present but isn't, rule does not apply 165 | return false 166 | } 167 | if !a.Invert && !*a.IsPresent && field != "" { 168 | // Field is expected not to be present but is, rule does not apply 169 | return false 170 | } 171 | if a.Invert && *a.IsPresent && field != "" { 172 | // Field is expected not to be present but is, rule does not apply 173 | return false 174 | } 175 | if a.Invert && !*a.IsPresent && field == "" { 176 | // Field is expected to be present but isn't, rule does not apply 177 | return false 178 | } 179 | 180 | return true 181 | } 182 | 183 | if field == "" { 184 | // We found a rule which has no matching field, rule does not apply 185 | return false 186 | } 187 | 188 | if a.MatchString != nil { 189 | if (*a.MatchString != value) == !a.Invert { 190 | // Value does not match expected string, rule does not apply 191 | return false 192 | } 193 | } 194 | 195 | if a.MatchRegex != nil { 196 | if regexp.MustCompile(*a.MatchRegex).MatchString(value) == a.Invert { 197 | // Value does not match expected regexp, rule does not apply 198 | return false 199 | } 200 | } 201 | 202 | return true 203 | } 204 | 205 | func (a aclRule) Validate() error { 206 | if a.Field == "" { 207 | return fmt.Errorf("field is not set") 208 | } 209 | 210 | if a.IsPresent == nil && a.MatchRegex == nil && a.MatchString == nil { 211 | return fmt.Errorf("no matcher (present, regexp, equals) is set") 212 | } 213 | 214 | if a.MatchRegex != nil { 215 | if _, err := regexp.Compile(*a.MatchRegex); err != nil { 216 | return fmt.Errorf("regexp is invalid: %s", err) 217 | } 218 | } 219 | 220 | return nil 221 | } 222 | 223 | // --- ACL Rule Set 224 | 225 | // AppliesToRequest checks whether every rule in the aclRuleSet 226 | // matches the http.Request. If not this rule-set must not be applied 227 | // to the given request 228 | func (a aclRuleSet) AppliesToRequest(r *http.Request) bool { 229 | fields := a.buildFieldSet(r) 230 | 231 | for _, rule := range a.Rules { 232 | if !rule.AppliesToFields(fields) { 233 | // At least one rule does not match the request 234 | return false 235 | } 236 | } 237 | 238 | return true 239 | } 240 | 241 | func (a aclRuleSet) Validate() error { 242 | for i, r := range a.Rules { 243 | if err := r.Validate(); err != nil { 244 | return fmt.Errorf("rule on position %d is invalid: %s", i+1, err) 245 | } 246 | } 247 | 248 | return nil 249 | } 250 | 251 | func (a aclRuleSet) buildFieldSet(r *http.Request) map[string]string { 252 | result := map[string]string{} 253 | 254 | for k := range r.Header { 255 | result[strings.ToLower(k)] = r.Header.Get(k) 256 | } 257 | 258 | return result 259 | } 260 | -------------------------------------------------------------------------------- /acl_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var ( 11 | aclTestUser = "test" 12 | aclTestGroups = []string{"group_a", "group_b"} 13 | ptrBoolTrue = func(v bool) *bool { return &v }(true) 14 | ) 15 | 16 | func aclTestRequest(headers map[string]string) *http.Request { 17 | req, _ := http.NewRequest("GET", "http://localhost/auth", nil) 18 | for k, v := range headers { 19 | req.Header.Set(k, v) 20 | } 21 | return req 22 | } 23 | 24 | func aclTestString(in string) *string { return &in } 25 | func aclTestBool(in bool) *bool { return &in } 26 | 27 | func TestEmptyACL(t *testing.T) { 28 | a := acl{} 29 | 30 | if a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(map[string]string{})) { 31 | t.Fatal("Empty ACL (= default action) was ALLOW instead of DENY") 32 | } 33 | } 34 | 35 | func TestRuleSetMatcher(t *testing.T) { 36 | r := aclRuleSet{ 37 | Rules: []aclRule{ 38 | { 39 | Field: "field_a", 40 | MatchString: aclTestString("expected"), 41 | }, 42 | { 43 | Field: "field_c", 44 | MatchString: aclTestString("expected"), 45 | }, 46 | }, 47 | Allow: []string{aclTestUser}, 48 | } 49 | fields := map[string]string{ 50 | "field_a": "expected", 51 | "field_b": "unchecked", 52 | "field_c": "expected", 53 | } 54 | 55 | assert.True(t, r.AppliesToRequest(aclTestRequest(fields))) 56 | 57 | delete(fields, "field_c") 58 | assert.False(t, r.AppliesToRequest(aclTestRequest(fields))) 59 | } 60 | 61 | func TestGroupAuthenticated(t *testing.T) { 62 | a := acl{RuleSets: []aclRuleSet{{ 63 | Rules: []aclRule{ 64 | { 65 | Field: "field_a", 66 | MatchString: aclTestString("expected"), 67 | }, 68 | }, 69 | Allow: []string{"@_authenticated"}, 70 | }}} 71 | fields := map[string]string{ 72 | "field_a": "expected", 73 | } 74 | 75 | assert.True(t, a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(fields))) 76 | assert.False(t, a.HasAccess("\x00", nil, aclTestRequest(fields)), "access to anon user") 77 | assert.False(t, a.HasAccess("", nil, aclTestRequest(fields)), "access to empty user") 78 | 79 | a.RuleSets[0].Allow = []string{"testgroup"} 80 | assert.False(t, a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(fields))) 81 | } 82 | 83 | func TestAnonymousAccess(t *testing.T) { 84 | a := acl{RuleSets: []aclRuleSet{{ 85 | Rules: []aclRule{ 86 | { 87 | Field: "field_a", 88 | MatchString: aclTestString("expected"), 89 | }, 90 | }, 91 | Allow: []string{groupAnonymous}, 92 | }}} 93 | fields := map[string]string{ 94 | "field_a": "expected", 95 | } 96 | 97 | assert.True(t, a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(fields))) 98 | assert.True(t, a.HasAccess("", nil, aclTestRequest(fields)), "access to empty user") 99 | assert.True(t, a.HasAccess("\x00", nil, aclTestRequest(fields)), "access to anon user") 100 | } 101 | 102 | func TestAnonymousAccessExplicitDeny(t *testing.T) { 103 | a := acl{ 104 | RuleSets: []aclRuleSet{ 105 | { 106 | Rules: []aclRule{{Field: "field_a", IsPresent: ptrBoolTrue}}, 107 | Allow: []string{groupAnonymous}, 108 | }, 109 | { 110 | Rules: []aclRule{{Field: "field_b", IsPresent: ptrBoolTrue}}, 111 | Allow: []string{"somerandomuser"}, 112 | Deny: []string{groupAnonymous}, 113 | }, 114 | }, 115 | } 116 | 117 | assert.True(t, 118 | a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(map[string]string{"field_a": ""})), 119 | "anon access with only allowed field should be possible") 120 | assert.False(t, 121 | a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(map[string]string{"field_b": ""})), 122 | "anon access with only denied field should not be possible") 123 | assert.False(t, 124 | a.HasAccess(aclTestUser, aclTestGroups, aclTestRequest(map[string]string{"field_a": "", "field_b": ""})), 125 | "anon access with one allowed and one denied field should not be possible") 126 | } 127 | 128 | func TestInvertedRegexMatcher(t *testing.T) { 129 | fields := map[string]string{ 130 | "field_a": "expected", 131 | "field_b": "unchecked", 132 | } 133 | 134 | ar := aclRule{ 135 | Field: "field_a", 136 | Invert: true, 137 | MatchRegex: aclTestString("^expected$"), 138 | } 139 | 140 | if ar.AppliesToFields(fields) { 141 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 142 | } 143 | 144 | fields["field_a"] = "unexpected" 145 | 146 | if !ar.AppliesToFields(fields) { 147 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 148 | } 149 | } 150 | 151 | func TestRegexMatcher(t *testing.T) { 152 | fields := map[string]string{ 153 | "field_a": "expected", 154 | "field_b": "unchecked", 155 | } 156 | 157 | ar := aclRule{ 158 | Field: "field_a", 159 | MatchRegex: aclTestString("^expected$"), 160 | } 161 | 162 | if !ar.AppliesToFields(fields) { 163 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 164 | } 165 | 166 | fields["field_a"] = "unexpected" 167 | 168 | if ar.AppliesToFields(fields) { 169 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 170 | } 171 | } 172 | 173 | func TestInvertedEqualsMatcher(t *testing.T) { 174 | fields := map[string]string{ 175 | "field_a": "expected", 176 | "field_b": "unchecked", 177 | } 178 | 179 | ar := aclRule{ 180 | Field: "field_a", 181 | Invert: true, 182 | MatchString: aclTestString("expected"), 183 | } 184 | 185 | if ar.AppliesToFields(fields) { 186 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 187 | } 188 | 189 | fields["field_a"] = "unexpected" 190 | 191 | if !ar.AppliesToFields(fields) { 192 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 193 | } 194 | } 195 | 196 | func TestEqualsMatcher(t *testing.T) { 197 | fields := map[string]string{ 198 | "field_a": "expected", 199 | "field_b": "unchecked", 200 | } 201 | 202 | ar := aclRule{ 203 | Field: "field_a", 204 | MatchString: aclTestString("expected"), 205 | } 206 | 207 | if !ar.AppliesToFields(fields) { 208 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 209 | } 210 | 211 | fields["field_a"] = "unexpected" 212 | 213 | if ar.AppliesToFields(fields) { 214 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 215 | } 216 | } 217 | 218 | func TestInvertedIsPresentMatcher(t *testing.T) { 219 | fields := map[string]string{ 220 | "field_a": "expected", 221 | "field_b": "unchecked", 222 | } 223 | 224 | ar := aclRule{ 225 | Field: "field_a", 226 | Invert: true, 227 | IsPresent: aclTestBool(true), 228 | } 229 | 230 | if ar.AppliesToFields(fields) { 231 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 232 | } 233 | 234 | ar.IsPresent = aclTestBool(false) 235 | 236 | if !ar.AppliesToFields(fields) { 237 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 238 | } 239 | 240 | ar.IsPresent = aclTestBool(true) 241 | delete(fields, "field_a") 242 | 243 | if !ar.AppliesToFields(fields) { 244 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 245 | } 246 | 247 | ar.IsPresent = aclTestBool(false) 248 | if ar.AppliesToFields(fields) { 249 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 250 | } 251 | } 252 | 253 | func TestIsPresentMatcher(t *testing.T) { 254 | fields := map[string]string{ 255 | "field_a": "expected", 256 | "field_b": "unchecked", 257 | } 258 | 259 | ar := aclRule{ 260 | Field: "field_a", 261 | IsPresent: aclTestBool(true), 262 | } 263 | 264 | if !ar.AppliesToFields(fields) { 265 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 266 | } 267 | 268 | ar.IsPresent = aclTestBool(false) 269 | 270 | if ar.AppliesToFields(fields) { 271 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 272 | } 273 | 274 | ar.IsPresent = aclTestBool(true) 275 | delete(fields, "field_a") 276 | 277 | if ar.AppliesToFields(fields) { 278 | t.Errorf("Rule %#v matches fields %#v", ar, fields) 279 | } 280 | 281 | ar.IsPresent = aclTestBool(false) 282 | if !ar.AppliesToFields(fields) { 283 | t.Errorf("Rule %#v does not match fields %#v", ar, fields) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /audit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "path" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/pkg/errors" 15 | 16 | "github.com/Luzifer/go_helpers/v2/str" 17 | ) 18 | 19 | type auditEvent string 20 | 21 | const ( 22 | auditEventAccessDenied = "access_denied" 23 | auditEventLoginFailure = "login_failure" 24 | auditEventLoginSuccess auditEvent = "login_success" 25 | auditEventLogout = "logout" 26 | auditEventValidate = "validate" 27 | ) 28 | 29 | type auditLogger struct { 30 | Targets []string `yaml:"targets"` 31 | Events []string `yaml:"events"` 32 | Headers []string `yaml:"headers"` 33 | TrustedIPHeaders []string `yaml:"trusted_ip_headers"` 34 | 35 | lock sync.Mutex 36 | } 37 | 38 | func (a *auditLogger) Log(event auditEvent, r *http.Request, extraFields map[string]string) error { 39 | if len(a.Targets) == 0 { 40 | return nil 41 | } 42 | 43 | if !str.StringInSlice(string(event), a.Events) { 44 | return nil 45 | } 46 | 47 | // Ensure order of logs, prevent file operation collisions 48 | a.lock.Lock() 49 | defer a.lock.Unlock() 50 | 51 | // Compile log event 52 | evt := map[string]interface{}{} 53 | evt["timestamp"] = time.Now().Format(time.RFC3339) 54 | evt["event_type"] = event 55 | evt["remote_addr"] = a.findIP(r) 56 | 57 | for k, v := range extraFields { 58 | evt[k] = v 59 | } 60 | 61 | headers := map[string]string{} 62 | for _, k := range a.Headers { 63 | if v := r.Header.Get(k); v != "" { 64 | headers[k] = v 65 | } 66 | } 67 | 68 | evt["headers"] = headers 69 | 70 | // Submit event to all specified targets 71 | for _, target := range a.Targets { 72 | if err := a.submitLog(target, evt); err != nil { 73 | return errors.Wrapf(err, "Could not submit log to target %q", target) 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (a *auditLogger) findIP(r *http.Request) string { 81 | remoteAddr := strings.SplitN(r.RemoteAddr, ":", 2)[0] 82 | 83 | for _, hdr := range a.TrustedIPHeaders { 84 | if value := r.Header.Get(hdr); value != "" { 85 | return strings.SplitN(value, ",", 2)[0] 86 | } 87 | } 88 | 89 | return remoteAddr 90 | } 91 | 92 | func (a *auditLogger) submitLog(target string, event map[string]interface{}) error { 93 | u, err := url.Parse(target) 94 | if err != nil { 95 | return errors.Wrap(err, "Unable to parse target") 96 | } 97 | 98 | switch u.Scheme { 99 | case "fd": 100 | return a.submitLogFileDescriptor(u.Host, event) 101 | case "file": 102 | return a.submitLogFile(u.Path, event) 103 | default: 104 | return errors.Errorf("Unsupported target scheme %q", u.Scheme) 105 | } 106 | } 107 | 108 | func (a *auditLogger) submitLogFile(filename string, event map[string]interface{}) error { 109 | if err := os.MkdirAll(path.Dir(filename), 0600); err != nil { 110 | return errors.Wrap(err, "Unable to create required paths") 111 | } 112 | 113 | f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) 114 | if err != nil { 115 | return errors.Wrap(err, "Unable to open audit file") 116 | } 117 | defer f.Close() 118 | 119 | return json.NewEncoder(f).Encode(event) 120 | } 121 | 122 | func (a *auditLogger) submitLogFileDescriptor(descriptor string, event map[string]interface{}) error { 123 | var w io.Writer 124 | 125 | switch descriptor { 126 | case "stdout": 127 | w = os.Stdout 128 | case "stderr": 129 | w = os.Stderr 130 | default: 131 | return errors.Errorf("Unsupported file descriptor %q", descriptor) 132 | } 133 | 134 | return errors.Wrap(json.NewEncoder(w).Encode(event), "Unable to marshal event") 135 | } 136 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | login: 4 | title: "luzifer.io - Login" 5 | default_method: "simple" 6 | default_redirect: "https://luzifer.io/" 7 | hide_mfa_field: false 8 | names: 9 | simple: "Username / Password" 10 | yubikey: "Yubikey" 11 | 12 | cookie: 13 | domain: ".example.com" 14 | authentication_key: "Ff1uWJcLouKu9kwxgbnKcU3ps47gps72sxEz79TGHFCpJNCPtiZAFDisM4MWbstH" 15 | expire: 3600 # Optional, default: 3600 16 | prefix: "nginx-sso" # Optional, default: nginx-sso 17 | secure: true # Optional, default: false 18 | 19 | # Optional, default: 127.0.0.1:8082 20 | listen: 21 | addr: "127.0.0.1" 22 | port: 8082 23 | 24 | audit_log: 25 | targets: 26 | - fd://stdout 27 | - file:///var/log/nginx-sso/audit.jsonl 28 | events: ['access_denied', 'login_success', 'login_failure', 'logout', 'validate'] 29 | headers: ['x-origin-uri'] 30 | trusted_ip_headers: ["X-Forwarded-For", "RemoteAddr", "X-Real-IP"] 31 | 32 | acl: 33 | rule_sets: 34 | - rules: 35 | - field: "host" 36 | equals: "test.example.com" 37 | - field: "x-origin-uri" 38 | regexp: "^/api" 39 | allow: ["luzifer", "@admins"] 40 | 41 | mfa: 42 | yubikey: 43 | # Get your client / secret from https://upgrade.yubico.com/getapikey/ 44 | client_id: "12345" 45 | secret_key: "foobar" 46 | 47 | duo: 48 | # Get your ikey / skey / host from https://duo.com/docs/duoweb#first-steps 49 | ikey: "IKEY" 50 | skey: "SKEY" 51 | host: "HOST" 52 | user_agent: "nginx-sso" 53 | 54 | plugins: 55 | directory: ./plugins/ 56 | 57 | providers: 58 | # Authentication against an Atlassian Crowd directory server 59 | # Supports: Users, Groups 60 | crowd: 61 | url: "https://crowd.example.com/crowd/" 62 | app_name: "" 63 | app_pass: "" 64 | 65 | # Authentication through OAuth2 workflow with Google Account 66 | # Supports: Users 67 | google_oauth: 68 | client_id: "" 69 | client_secret: "" 70 | redirect_url: "https://login.luifer.io/login" 71 | 72 | # Optional, defaults to no limitations 73 | require_domain: "example.com" 74 | # Optional, defaults to "user-id" 75 | user_id_method: "full-email" 76 | 77 | # Authentication against (Open)LDAP server 78 | # Supports: Users, Groups 79 | ldap: 80 | enable_basic_auth: false 81 | manager_dn: "cn=admin,dc=example,dc=com" 82 | manager_password: "" 83 | root_dn: "dc=example,dc=com" 84 | server: "ldap://ldap.example.com" 85 | # Optional, defaults to root_dn 86 | user_search_base: ou=users,dc=example,dc=com 87 | # Optional, defaults to '(uid={0})' 88 | user_search_filter: "" 89 | # Optional, defaults to root_dn 90 | group_search_base: "ou=groups,dc=example,dc=com" 91 | # Optional, defaults to '(|(member={0})(uniqueMember={0}))' 92 | group_membership_filter: "" 93 | # Replace DN as the username with another attribute 94 | # Optional, defaults to "dn" 95 | username_attribute: "uid" 96 | # Configure TLS parameters for LDAPs connections 97 | # Optional, defaults to null 98 | tls_config: 99 | # Set the hostname for certificate validation 100 | # Optional, defaults to host from the connection URI 101 | validate_hostname: ldap.example.com 102 | # Disable certificate validation 103 | # Optional, defaults to false 104 | allow_insecure: false 105 | 106 | # Authentication through OAuth2 workflow with OpenID Connect provider 107 | # Supports: Users 108 | oidc: 109 | client_id: "" 110 | client_secret: "" 111 | # Optional, defaults to "OpenID Connect" 112 | issuer_name: "" 113 | issuer_url: "" 114 | redirect_url: "https://login.luifer.io/login" 115 | 116 | # Optional, defaults to no limitations 117 | require_domain: "example.com" 118 | # Optional, defaults to "subject" 119 | user_id_method: "full-email" 120 | 121 | 122 | # Authentication against embedded user database 123 | # Supports: Users, Groups, MFA 124 | simple: 125 | enable_basic_auth: false 126 | 127 | # Unique username mapped to bcrypt hashed password 128 | users: 129 | luzifer: "$2a$10$FSGAF8qDWX52aBID8.WpxOyCvfSQ3JIUVFiwyd1jolb4jM3BzJmNu" 130 | 131 | # Groupname to users mapping 132 | groups: 133 | admins: ["luzifer"] 134 | 135 | # MFA configs: Username to configs mapping 136 | mfa: 137 | luzifer: 138 | - provider: duo 139 | 140 | - provider: totp 141 | attributes: 142 | secret: MZXW6YTBOIFA # required 143 | period: 30 # optional, defaults to 30 (Google Authenticator) 144 | skew: 1 # optional, defaults to 1 (Google Authenticator) 145 | digits: 8 # optional, defaults to 6 (Google Authenticator) 146 | algorithm: sha1 # optional (sha1, sha256, sha512), defaults to sha1 (Google Authenticator) 147 | 148 | - provider: yubikey 149 | attributes: 150 | device: ccccccfcvuul 151 | 152 | # Authentication against embedded token directory 153 | # Supports: Users, Groups 154 | token: 155 | # Mapping of unique token names to the token 156 | tokens: 157 | tokenname: "MYTOKEN" 158 | 159 | # Groupname to token mapping 160 | groups: 161 | mytokengroup: ["tokenname"] 162 | 163 | # Authentication against Yubikey cloud validation servers 164 | # Supports: Users, Groups 165 | yubikey: 166 | # Get your client / secret from https://upgrade.yubico.com/getapikey/ 167 | client_id: "12345" 168 | secret_key: "foobar" 169 | 170 | # First 12 characters of the OTP string mapped to the username 171 | devices: 172 | ccccccfcvuul: "luzifer" 173 | 174 | # Groupname to users mapping 175 | groups: 176 | admins: ["luzifer"] 177 | 178 | ... 179 | -------------------------------------------------------------------------------- /core.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Luzifer/nginx-sso/plugins/auth/crowd" 5 | "github.com/Luzifer/nginx-sso/plugins/auth/google" 6 | "github.com/Luzifer/nginx-sso/plugins/auth/ldap" 7 | "github.com/Luzifer/nginx-sso/plugins/auth/oidc" 8 | "github.com/Luzifer/nginx-sso/plugins/auth/simple" 9 | "github.com/Luzifer/nginx-sso/plugins/auth/token" 10 | auth_yubikey "github.com/Luzifer/nginx-sso/plugins/auth/yubikey" 11 | "github.com/Luzifer/nginx-sso/plugins/mfa/duo" 12 | "github.com/Luzifer/nginx-sso/plugins/mfa/totp" 13 | mfa_yubikey "github.com/Luzifer/nginx-sso/plugins/mfa/yubikey" 14 | ) 15 | 16 | func registerModules() { 17 | // Start with very simple, local auth providers as they are cheap 18 | // in their execution and therefore if they are used nginx-sso 19 | // can process far more requests than through the other providers 20 | registerAuthenticator(simple.New(cookieStore)) 21 | registerAuthenticator(token.New()) 22 | 23 | // Afterwards utilize the more expensive remove providers 24 | registerAuthenticator(crowd.New()) 25 | registerAuthenticator(ldap.New(cookieStore)) 26 | registerAuthenticator(google.New(cookieStore)) 27 | registerAuthenticator(oidc.New(cookieStore)) 28 | registerAuthenticator(auth_yubikey.New(cookieStore)) 29 | 30 | registerMFAProvider(duo.New()) 31 | registerMFAProvider(totp.New()) 32 | registerMFAProvider(mfa_yubikey.New()) 33 | } 34 | -------------------------------------------------------------------------------- /docker-start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/dumb-init /bin/bash 2 | set -euo pipefail 3 | 4 | # Copy frontend if not available 5 | [ -f /data/frontend/index.html ] || cp -r /usr/local/share/nginx-sso/frontend /data/ 6 | 7 | [ -e /data/config.yaml ] || { 8 | cp /usr/local/share/nginx-sso/config.yaml /data/config.yaml 9 | echo "An example configuration was copied to /data/config.yaml - You want to edit that one!" 10 | exit 1 11 | } 12 | 13 | echo "Starting nginx-sso" 14 | exec /usr/local/bin/nginx-sso \ 15 | --config /data/config.yaml \ 16 | --frontend-dir /data/frontend \ 17 | $@ 18 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ login.Title }} 9 | 10 | 11 | 13 | 14 | 22 | 23 | 24 | {% verbatim %} 25 |
26 | 27 |
28 | 29 |
30 |
31 |

{{ login.title }}

32 |
33 |
34 |
35 | 36 |
37 |
38 |
Login with
39 |
40 | 49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 | 57 | 58 |
63 | 66 | 75 |
76 | 77 |
78 | 79 |
80 | 81 |
82 |
83 | 84 |
85 |
86 |
87 | 88 |
89 | 90 |
91 | 92 | 93 | 95 | 96 | 98 | 99 | 101 | 102 | {% endverbatim %} 103 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Luzifer/nginx-sso 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/GeertJohan/yubigo v0.0.0-20190917122436-175bc097e60e 9 | github.com/Luzifer/go_helpers/v2 v2.25.0 10 | github.com/Luzifer/rconfig/v2 v2.5.2 11 | github.com/Masterminds/sprig/v3 v3.3.0 12 | github.com/coreos/go-oidc/v3 v3.11.0 13 | github.com/duosecurity/duo_api_golang v0.0.0-20240408132100-cb1770897e66 14 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 15 | github.com/gorilla/context v1.1.2 16 | github.com/gorilla/sessions v1.4.0 17 | github.com/jda/go-crowd v0.0.0-20180225080536-9c6f17811dc6 18 | github.com/pkg/errors v0.9.1 19 | github.com/pquerna/otp v1.4.0 20 | github.com/sirupsen/logrus v1.9.3 21 | github.com/stretchr/testify v1.9.0 22 | golang.org/x/crypto v0.31.0 23 | golang.org/x/oauth2 v0.24.0 24 | google.golang.org/api v0.211.0 25 | gopkg.in/ldap.v2 v2.5.1 26 | gopkg.in/yaml.v3 v3.0.1 27 | ) 28 | 29 | require ( 30 | cloud.google.com/go/auth v0.12.1 // indirect 31 | cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect 32 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 33 | dario.cat/mergo v1.0.1 // indirect 34 | github.com/Masterminds/goutils v1.1.1 // indirect 35 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 36 | github.com/boombuler/barcode v1.0.2 // indirect 37 | github.com/davecgh/go-spew v1.1.1 // indirect 38 | github.com/felixge/httpsnoop v1.0.4 // indirect 39 | github.com/go-jose/go-jose/v4 v4.0.4 // indirect 40 | github.com/go-logr/logr v1.4.2 // indirect 41 | github.com/go-logr/stdr v1.2.2 // indirect 42 | github.com/google/s2a-go v0.1.8 // indirect 43 | github.com/google/uuid v1.6.0 // indirect 44 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 45 | github.com/googleapis/gax-go/v2 v2.14.0 // indirect 46 | github.com/gorilla/securecookie v1.1.2 // indirect 47 | github.com/huandu/xstrings v1.5.0 // indirect 48 | github.com/mitchellh/copystructure v1.2.0 // indirect 49 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 50 | github.com/pmezard/go-difflib v1.0.0 // indirect 51 | github.com/shopspring/decimal v1.4.0 // indirect 52 | github.com/spf13/cast v1.7.0 // indirect 53 | github.com/spf13/pflag v1.0.5 // indirect 54 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect 55 | go.opentelemetry.io/otel v1.32.0 // indirect 56 | go.opentelemetry.io/otel/metric v1.32.0 // indirect 57 | go.opentelemetry.io/otel/trace v1.32.0 // indirect 58 | golang.org/x/net v0.32.0 // indirect 59 | golang.org/x/sys v0.28.0 // indirect 60 | golang.org/x/text v0.21.0 // indirect 61 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect 62 | google.golang.org/grpc v1.68.1 // indirect 63 | google.golang.org/protobuf v1.35.2 // indirect 64 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect 65 | gopkg.in/validator.v2 v2.0.1 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/auth v0.12.1 h1:n2Bj25BUMM0nvE9D2XLTiImanwZhO3DkfWSYS/SAJP4= 2 | cloud.google.com/go/auth v0.12.1/go.mod h1:BFMu+TNpF3DmvfBO9ClqTR/SiqVIm7LukKF9mbendF4= 3 | cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU= 4 | cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= 5 | cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= 6 | cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= 7 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 8 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 9 | github.com/GeertJohan/yubigo v0.0.0-20190917122436-175bc097e60e h1:Bqtt5C+uVk+vH/t5dmB47uDCTwxw16EYHqvJnmY2aQc= 10 | github.com/GeertJohan/yubigo v0.0.0-20190917122436-175bc097e60e/go.mod h1:njRCDrl+1RQ/A/+KVU8Ho2EWAxUSkohOWczdW3dzDG0= 11 | github.com/Luzifer/go_helpers/v2 v2.25.0 h1:k1J4gd1+BfuokTDoWgcgib9P5mdadjzKEgbtKSVe46k= 12 | github.com/Luzifer/go_helpers/v2 v2.25.0/go.mod h1:KSVUdAJAav5cWGyB5oKGxmC27HrKULVTOxwPS/Kr+pc= 13 | github.com/Luzifer/rconfig/v2 v2.5.2 h1:4Bfp8mTrCCK/xghUmUbh/qtKiLZA6RC0tHTgqkNw1m4= 14 | github.com/Luzifer/rconfig/v2 v2.5.2/go.mod h1:HnqUWg+NQh60/neUqfMDDDo5d1v8UPuhwKR1HqM4VWQ= 15 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 16 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 17 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 18 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 19 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 20 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 21 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 22 | github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= 23 | github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 24 | github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= 25 | github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/duosecurity/duo_api_golang v0.0.0-20240408132100-cb1770897e66 h1:tQLJ09oUDyX6n6A3vv+MxllyQ9eSRMM+yLLGwMSyqUQ= 30 | github.com/duosecurity/duo_api_golang v0.0.0-20240408132100-cb1770897e66/go.mod h1:hJ6IPTuCAvWv+i9ubnPZB3VpVRuj/+SAblWFcI0mjEU= 31 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 32 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 33 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 h1:fmFk0Wt3bBxxwZnu48jqMdaOR/IZ4vdtJFuaFV8MpIE= 34 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3/go.mod h1:bJWSKrZyQvfTnb2OudyUjurSG4/edverV7n82+K3JiM= 35 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 36 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 37 | github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= 38 | github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 39 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 40 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 41 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 42 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 43 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 44 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 45 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 46 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 47 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 48 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 49 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 50 | github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= 51 | github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= 52 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 53 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 54 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= 55 | github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= 56 | github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o= 57 | github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk= 58 | github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 59 | github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 60 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 61 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 62 | github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 63 | github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 64 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 65 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 66 | github.com/jda/go-crowd v0.0.0-20180225080536-9c6f17811dc6 h1:wDO7xR6HTEPnXKG4Tku6nr5vepcEb1Ct4kB51j3Zvas= 67 | github.com/jda/go-crowd v0.0.0-20180225080536-9c6f17811dc6/go.mod h1:YapIHiLsT+0vQL2UBXLBjX+3SXYSXi2OyGgr7zXLnbc= 68 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 69 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 72 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 73 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 74 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 75 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 76 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 77 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 78 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 79 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 80 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 81 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 82 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 83 | github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= 84 | github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= 85 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 86 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 87 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 88 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 89 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 90 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 91 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 92 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 93 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 94 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 95 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 96 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 97 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 98 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 99 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 100 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= 101 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= 102 | go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= 103 | go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= 104 | go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= 105 | go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= 106 | go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= 107 | go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= 108 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 109 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 110 | golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= 111 | golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= 112 | golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= 113 | golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 114 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 115 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 116 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 118 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 119 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 120 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 121 | google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= 122 | google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= 123 | google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ= 124 | google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88= 125 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= 126 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= 127 | google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= 128 | google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= 129 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= 130 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 131 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= 132 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= 133 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 134 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 135 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 136 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 137 | gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= 138 | gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= 139 | gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= 140 | gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= 141 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 142 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 143 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 144 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "os/signal" 10 | "path" 11 | "strings" 12 | "syscall" 13 | "text/template" 14 | 15 | "github.com/Masterminds/sprig/v3" 16 | "github.com/flosch/pongo2" 17 | "github.com/gorilla/context" 18 | "github.com/gorilla/sessions" 19 | "github.com/pkg/errors" 20 | log "github.com/sirupsen/logrus" 21 | yaml "gopkg.in/yaml.v3" 22 | 23 | "github.com/Luzifer/nginx-sso/plugins" 24 | "github.com/Luzifer/rconfig/v2" 25 | ) 26 | 27 | type mainConfig struct { 28 | ACL acl `yaml:"acl"` 29 | AuditLog auditLogger `yaml:"audit_log"` 30 | Cookie plugins.CookieConfig `yaml:"cookie"` 31 | Listen struct { 32 | Addr string `yaml:"addr"` 33 | Port int `yaml:"port"` 34 | } `yaml:"listen"` 35 | Login struct { 36 | Title string `yaml:"title" json:"title"` 37 | DefaultMethod string `yaml:"default_method" json:"default_method"` 38 | DefaultRedirect string `yaml:"default_redirect" json:"default_redirect"` 39 | HideMFAField bool `yaml:"hide_mfa_field" json:"hide_mfa_field"` 40 | Names map[string]string `yaml:"names" json:"names"` 41 | } `yaml:"login"` 42 | Plugins struct { 43 | Directory string `yaml:"directory"` 44 | } `yaml:"plugins"` 45 | } 46 | 47 | var ( 48 | cfg = struct { 49 | ConfigFile string `flag:"config,c" default:"config.yaml" env:"CONFIG" description:"Location of the configuration file"` 50 | AuthKey string `flag:"authkey" env:"COOKIE_AUTHENTICATION_KEY" description:"Cookie authentication key"` 51 | LogLevel string `flag:"log-level" default:"info" description:"Level of logs to display (debug, info, warn, error)"` 52 | TemplateDir string `flag:"frontend-dir" default:"./frontend/" env:"FRONTEND_DIR" description:"Location of the directory containing the web assets"` 53 | VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` 54 | }{} 55 | 56 | mainCfg = mainConfig{} 57 | cookieStore *sessions.CookieStore 58 | 59 | version = "dev" 60 | ) 61 | 62 | func init() { 63 | rconfig.AutoEnv(true) 64 | if err := rconfig.Parse(&cfg); err != nil { 65 | log.WithError(err).Fatal("Unable to parse commandline options") 66 | } 67 | 68 | if l, err := log.ParseLevel(cfg.LogLevel); err != nil { 69 | log.WithError(err).Fatal("Unable to parse log level") 70 | } else { 71 | log.SetLevel(l) 72 | } 73 | 74 | if cfg.VersionAndExit { 75 | fmt.Printf("nginx-sso %s\n", version) 76 | os.Exit(0) 77 | } 78 | 79 | // Set sane defaults for main configuration 80 | mainCfg.Cookie = plugins.DefaultCookieConfig() 81 | mainCfg.Listen.Addr = "127.0.0.1" 82 | mainCfg.Listen.Port = 8082 83 | mainCfg.Login.DefaultRedirect = "debug" 84 | mainCfg.AuditLog.TrustedIPHeaders = []string{"X-Forwarded-For", "RemoteAddr", "X-Real-IP"} 85 | mainCfg.AuditLog.Headers = []string{"x-origin-uri"} 86 | } 87 | 88 | func loadConfiguration() ([]byte, error) { 89 | yamlSource, err := os.ReadFile(cfg.ConfigFile) 90 | if err != nil { 91 | return nil, errors.Wrap(err, "reading configuration file") 92 | } 93 | 94 | tpl, err := template.New("config").Funcs(sprig.FuncMap()).Parse(string(yamlSource)) 95 | if err != nil { 96 | return nil, errors.Wrap(err, "parsing config as template") 97 | } 98 | 99 | buf := new(bytes.Buffer) 100 | if err = tpl.Execute(buf, nil); err != nil { 101 | return nil, errors.Wrap(err, "executing config as template") 102 | } 103 | 104 | if err = yaml.Unmarshal(buf.Bytes(), &mainCfg); err != nil { 105 | return nil, errors.Wrap(err, "loading configuration file") 106 | } 107 | 108 | if cfg.AuthKey != "" { 109 | mainCfg.Cookie.AuthKey = cfg.AuthKey 110 | } 111 | 112 | return buf.Bytes(), nil 113 | } 114 | 115 | func initializeModules(yamlSource []byte) error { 116 | if mainCfg.Plugins.Directory != "" { 117 | if err := loadPlugins(mainCfg.Plugins.Directory); err != nil { 118 | return errors.Wrap(err, "Unable to load plugins") 119 | } 120 | } 121 | 122 | if err := initializeAuthenticators(yamlSource); err != nil { 123 | return fmt.Errorf("Unable to configure authentication: %s", err) 124 | } 125 | 126 | if err := initializeMFAProviders(yamlSource); err != nil { 127 | log.WithError(err).Fatal("Unable to configure MFA providers") 128 | } 129 | 130 | return nil 131 | } 132 | 133 | func main() { 134 | yamlSource, err := loadConfiguration() 135 | if err != nil { 136 | log.WithError(err).Fatal("Unable to load configuration") 137 | } 138 | 139 | cookieStore = sessions.NewCookieStore([]byte(mainCfg.Cookie.AuthKey)) 140 | registerModules() 141 | 142 | if err = initializeModules(yamlSource); err != nil { 143 | log.WithError(err).Fatal("Unable to initialize modules") 144 | } 145 | 146 | http.HandleFunc("/", handleRootRequest) 147 | http.HandleFunc("/auth", handleAuthRequest) 148 | http.HandleFunc("/debug", handleLoginDebug) 149 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) }) 150 | http.HandleFunc("/login", handleLoginRequest) 151 | http.HandleFunc("/logout", handleLogoutRequest) 152 | 153 | go http.ListenAndServe( 154 | fmt.Sprintf("%s:%d", mainCfg.Listen.Addr, mainCfg.Listen.Port), 155 | context.ClearHandler(http.DefaultServeMux), 156 | ) 157 | 158 | sigChan := make(chan os.Signal, 1) 159 | signal.Notify(sigChan, syscall.SIGHUP) 160 | 161 | for sig := range sigChan { 162 | switch sig { 163 | case syscall.SIGHUP: 164 | if yamlSource, err = loadConfiguration(); err != nil { 165 | log.WithError(err).Error("Unable to reload configuration") 166 | continue 167 | } 168 | 169 | if err = initializeModules(yamlSource); err != nil { 170 | log.WithError(err).Error("Unable to initialize modules") 171 | } 172 | 173 | default: 174 | log.Fatalf("Received unexpected signal: %v", sig) 175 | } 176 | } 177 | } 178 | 179 | func handleRootRequest(res http.ResponseWriter, r *http.Request) { 180 | // In case of a request to `/` redirect to login utilizing the default redirect 181 | http.Redirect(res, r, "login", http.StatusFound) 182 | } 183 | 184 | func handleAuthRequest(res http.ResponseWriter, r *http.Request) { 185 | user, groups, err := detectUser(res, r) 186 | 187 | switch err { 188 | case plugins.ErrNoValidUserFound: 189 | // No valid user found, check whether special anonymous "user" has access 190 | // Username is set to 0x0 character to prevent accidental whitelist-match 191 | if mainCfg.ACL.HasAccess(string(byte(0x0)), nil, r) { 192 | mainCfg.AuditLog.Log(auditEventValidate, r, map[string]string{"result": "anonymous access granted"}) // #nosec G104 - This is only logging 193 | res.WriteHeader(http.StatusOK) 194 | return 195 | } 196 | 197 | mainCfg.AuditLog.Log(auditEventValidate, r, map[string]string{"result": "no valid user found"}) // #nosec G104 - This is only logging 198 | http.Error(res, "No valid user found", http.StatusUnauthorized) 199 | 200 | case nil: 201 | if !mainCfg.ACL.HasAccess(user, groups, r) { 202 | mainCfg.AuditLog.Log(auditEventAccessDenied, r, map[string]string{"username": user}) // #nosec G104 - This is only logging 203 | http.Error(res, "Access denied for this resource", http.StatusForbidden) 204 | return 205 | } 206 | 207 | mainCfg.AuditLog.Log(auditEventValidate, r, map[string]string{"result": "valid user found", "username": user}) // #nosec G104 - This is only logging 208 | 209 | res.Header().Set("X-Username", user) 210 | res.WriteHeader(http.StatusOK) 211 | 212 | default: 213 | log.WithError(err).Error("Error while handling auth request") 214 | http.Error(res, "Something went wrong", http.StatusInternalServerError) 215 | } 216 | } 217 | 218 | func handleLoginRequest(res http.ResponseWriter, r *http.Request) { 219 | redirURL, err := getRedirectURL(r, mainCfg.Login.DefaultRedirect) 220 | if err != nil { 221 | http.Error(res, "Invalid redirect URL specified", http.StatusBadRequest) 222 | } 223 | 224 | if _, _, err := detectUser(res, r); err == nil { 225 | // There is already a valid user 226 | http.Redirect(res, r, redirURL, http.StatusFound) 227 | return 228 | } 229 | 230 | auditFields := map[string]string{ 231 | "go": redirURL, 232 | } 233 | 234 | if r.Method == "POST" || r.URL.Query().Get("code") != "" { 235 | // Simple authentication 236 | user, mfaCfgs, err := loginUser(res, r) 237 | switch err { 238 | case plugins.ErrNoValidUserFound: 239 | auditFields["reason"] = "invalid credentials" 240 | mainCfg.AuditLog.Log(auditEventLoginFailure, r, auditFields) // #nosec G104 - This is only logging 241 | http.Redirect(res, r, "/login?go="+url.QueryEscape(redirURL), http.StatusFound) 242 | return 243 | case nil: 244 | // Don't handle for now, MFA validation comes first 245 | default: 246 | auditFields["reason"] = "error" 247 | auditFields["error"] = err.Error() 248 | mainCfg.AuditLog.Log(auditEventLoginFailure, r, auditFields) // #nosec G104 - This is only logging 249 | log.WithError(err).Error("Login failed with unexpected error") 250 | http.Redirect(res, r, "/login?go="+url.QueryEscape(redirURL), http.StatusFound) 251 | return 252 | } 253 | 254 | // MFA validation against configs from login 255 | err = validateMFA(res, r, user, mfaCfgs) 256 | switch err { 257 | case plugins.ErrNoValidUserFound: 258 | auditFields["reason"] = "invalid credentials" 259 | mainCfg.AuditLog.Log(auditEventLoginFailure, r, auditFields) // #nosec G104 - This is only logging 260 | res.Header().Del("Set-Cookie") // Remove login cookie 261 | http.Redirect(res, r, "/login?go="+url.QueryEscape(redirURL), http.StatusFound) 262 | return 263 | 264 | case nil: 265 | mainCfg.AuditLog.Log(auditEventLoginSuccess, r, auditFields) // #nosec G104 - This is only logging 266 | http.Redirect(res, r, redirURL, http.StatusFound) 267 | return 268 | 269 | default: 270 | auditFields["reason"] = "error" 271 | auditFields["error"] = err.Error() 272 | mainCfg.AuditLog.Log(auditEventLoginFailure, r, auditFields) // #nosec G104 - This is only logging 273 | log.WithError(err).Error("Login failed with unexpected error") 274 | res.Header().Del("Set-Cookie") // Remove login cookie 275 | http.Redirect(res, r, "/login?go="+url.QueryEscape(redirURL), http.StatusFound) 276 | return 277 | } 278 | } 279 | 280 | // Store redirect URL in session (required for oAuth2 flows) 281 | sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, "main"}, "-")) // #nosec G104 - On error empty session is returned 282 | sess.Options = mainCfg.Cookie.GetSessionOpts() 283 | sess.Values["go"] = redirURL 284 | 285 | if err := sess.Save(r, res); err != nil { 286 | log.WithError(err).Error("Unable to save session") 287 | http.Error(res, "Something went wrong", http.StatusInternalServerError) 288 | } 289 | 290 | // Render login page 291 | tpl := pongo2.Must(pongo2.FromFile(path.Join(cfg.TemplateDir, "index.html"))) 292 | if err := tpl.ExecuteWriter(pongo2.Context{ 293 | "active_methods": getFrontendAuthenticators(), 294 | "go": redirURL, 295 | "login": mainCfg.Login, 296 | }, res); err != nil { 297 | log.WithError(err).Error("Unable to render template") 298 | http.Error(res, "Something went wrong", http.StatusInternalServerError) 299 | } 300 | } 301 | 302 | func handleLogoutRequest(res http.ResponseWriter, r *http.Request) { 303 | redirURL, err := getRedirectURL(r, mainCfg.Login.DefaultRedirect) 304 | if err != nil { 305 | http.Error(res, "Invalid redirect URL specified", http.StatusBadRequest) 306 | } 307 | 308 | mainCfg.AuditLog.Log(auditEventLogout, r, nil) // #nosec G104 - This is only logging 309 | if err := logoutUser(res, r); err != nil { 310 | log.WithError(err).Error("Failed to logout user") 311 | http.Error(res, "Something went wrong", http.StatusInternalServerError) 312 | return 313 | } 314 | 315 | http.Redirect(res, r, redirURL, http.StatusFound) 316 | } 317 | 318 | func handleLoginDebug(w http.ResponseWriter, r *http.Request) { 319 | user, groups, err := detectUser(w, r) 320 | switch err { 321 | case nil: 322 | // All fine 323 | 324 | case plugins.ErrNoValidUserFound: 325 | http.Redirect(w, r, "login", http.StatusFound) 326 | return 327 | 328 | default: 329 | log.WithError(err).Error("Failed to get user for login debug") 330 | http.Error(w, "Something went wrong", http.StatusInternalServerError) 331 | } 332 | 333 | w.WriteHeader(http.StatusOK) 334 | fmt.Fprintln(w, "Successfully logged in:") 335 | fmt.Fprintf(w, "- Username: %s\n", user) 336 | fmt.Fprintf(w, "- Groups: %s\n", strings.Join(groups, ",")) 337 | } 338 | -------------------------------------------------------------------------------- /mfa.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/Luzifer/nginx-sso/plugins" 11 | ) 12 | 13 | var mfaLoginField = plugins.LoginField{ 14 | Label: "MFA Token", 15 | Name: plugins.MFALoginFieldName, 16 | Placeholder: "(optional)", 17 | Type: "text", 18 | } 19 | 20 | var ( 21 | mfaRegistry = []plugins.MFAProvider{} 22 | mfaRegistryMutex sync.RWMutex 23 | 24 | activeMFAProviders = []plugins.MFAProvider{} 25 | ) 26 | 27 | func registerMFAProvider(m plugins.MFAProvider) { 28 | mfaRegistryMutex.Lock() 29 | defer mfaRegistryMutex.Unlock() 30 | 31 | mfaRegistry = append(mfaRegistry, m) 32 | } 33 | 34 | func initializeMFAProviders(yamlSource []byte) error { 35 | mfaRegistryMutex.Lock() 36 | defer mfaRegistryMutex.Unlock() 37 | 38 | for _, m := range mfaRegistry { 39 | err := m.Configure(yamlSource) 40 | 41 | switch err { 42 | case nil: 43 | activeMFAProviders = append(activeMFAProviders, m) 44 | log.WithFields(log.Fields{"mfa_provider": m.ProviderID()}).Debug("Activated MFA provider") 45 | case plugins.ErrProviderUnconfigured: 46 | log.WithFields(log.Fields{"mfa_provider": m.ProviderID()}).Debug("MFA provider unconfigured") 47 | // This is okay. 48 | default: 49 | return fmt.Errorf("MFA provider configuration caused an error: %s", err) 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func validateMFA(res http.ResponseWriter, r *http.Request, user string, mfaCfgs []plugins.MFAConfig) error { 57 | if len(mfaCfgs) == 0 { 58 | // User has no configured MFA devices, their MFA is automatically valid 59 | return nil 60 | } 61 | 62 | mfaRegistryMutex.RLock() 63 | defer mfaRegistryMutex.RUnlock() 64 | 65 | for _, m := range activeMFAProviders { 66 | err := m.ValidateMFA(res, r, user, mfaCfgs) 67 | switch err { 68 | case nil: 69 | // Validated successfully 70 | return nil 71 | case plugins.ErrNoValidUserFound: 72 | // This is fine for now 73 | default: 74 | return err 75 | } 76 | } 77 | 78 | // No method could verify the user 79 | return plugins.ErrNoValidUserFound 80 | } 81 | -------------------------------------------------------------------------------- /plugins.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "plugin" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/Luzifer/nginx-sso/plugins" 14 | ) 15 | 16 | func loadPlugins(pluginDir string) error { 17 | logger := log.WithField("plugin_dir", pluginDir) 18 | 19 | d, err := os.Stat(pluginDir) 20 | if err != nil { 21 | if os.IsNotExist(err) { 22 | logger.Warn("Plugin directory not found, skipping") 23 | return nil 24 | } 25 | return errors.Wrap(err, "Could not stat plugin dir") 26 | } 27 | 28 | if !d.IsDir() { 29 | return errors.New("Plugin directory is not a directory") 30 | } 31 | 32 | return errors.Wrap(filepath.Walk(pluginDir, func(currentPath string, info os.FileInfo, err error) error { 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if !strings.HasSuffix(currentPath, ".so") { 38 | // Ignore that file, is not a plugin 39 | return nil 40 | } 41 | 42 | logger := log.WithField("plugin", path.Base(currentPath)) 43 | 44 | p, err := plugin.Open(currentPath) 45 | if err != nil { 46 | logger.WithError(err).Error("Unable to open plugin") 47 | return nil 48 | } 49 | 50 | f, err := p.Lookup("Register") 51 | if err != nil { 52 | logger.WithError(err).Error("Unable to find register function") 53 | return nil 54 | } 55 | 56 | f.(func(plugins.RegisterAuthenticatorFunc, plugins.RegisterMFAProviderFunc))( 57 | registerAuthenticator, 58 | registerMFAProvider, 59 | ) 60 | 61 | return nil 62 | }), "Unable to load plugins") 63 | } 64 | -------------------------------------------------------------------------------- /plugins/auth.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import "net/http" 4 | 5 | type Authenticator interface { 6 | // AuthenticatorID needs to return an unique string to identify 7 | // this special authenticator 8 | AuthenticatorID() (id string) 9 | 10 | // Configure loads the configuration for the Authenticator from the 11 | // global config.yaml file which is passed as a byte-slice. 12 | // If no configuration for the Authenticator is supplied the function 13 | // needs to return the ErrProviderUnconfigured 14 | Configure(yamlSource []byte) (err error) 15 | 16 | // DetectUser is used to detect a user without a login form from 17 | // a cookie, header or other methods 18 | // If no user was detected the ErrNoValidUserFound needs to be 19 | // returned 20 | DetectUser(res http.ResponseWriter, r *http.Request) (user string, groups []string, err error) 21 | 22 | // Login is called when the user submits the login form and needs 23 | // to authenticate the user or throw an error. If the user has 24 | // successfully logged in the persistent cookie should be written 25 | // in order to use DetectUser for the next login. 26 | // With the login result an array of mfaConfig must be returned. In 27 | // case there is no MFA config or the provider does not support MFA 28 | // return nil. 29 | // If the user did not login correctly the ErrNoValidUserFound 30 | // needs to be returned 31 | Login(res http.ResponseWriter, r *http.Request) (user string, mfaConfigs []MFAConfig, err error) 32 | 33 | // LoginFields needs to return the fields required for this login 34 | // method. If no login using this method is possible the function 35 | // needs to return nil. 36 | LoginFields() (fields []LoginField) 37 | 38 | // Logout is called when the user visits the logout endpoint and 39 | // needs to destroy any persistent stored cookies 40 | Logout(res http.ResponseWriter, r *http.Request) (err error) 41 | 42 | // SupportsMFA returns the MFA detection capabilities of the login 43 | // provider. If the provider can provide mfaConfig objects from its 44 | // configuration return true. If this is true the login interface 45 | // will display an additional field for this provider for the user 46 | // to fill in their MFA token. 47 | SupportsMFA() bool 48 | } 49 | 50 | type LoginField struct { 51 | Action string `json:"action"` 52 | Label string `json:"label"` 53 | Name string `json:"name"` 54 | Placeholder string `json:"placeholder"` 55 | Type string `json:"type"` 56 | } 57 | -------------------------------------------------------------------------------- /plugins/auth/crowd/auth.go: -------------------------------------------------------------------------------- 1 | package crowd 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | crowd "github.com/jda/go-crowd" 8 | log "github.com/sirupsen/logrus" 9 | yaml "gopkg.in/yaml.v3" 10 | 11 | "github.com/Luzifer/nginx-sso/plugins" 12 | ) 13 | 14 | type AuthCrowd struct { 15 | URL string `yaml:"url"` 16 | AppName string `yaml:"app_name"` 17 | AppPassword string `yaml:"app_pass"` 18 | 19 | crowd crowd.Crowd 20 | } 21 | 22 | func New() *AuthCrowd { 23 | return &AuthCrowd{} 24 | } 25 | 26 | // AuthenticatorID needs to return an unique string to identify 27 | // this special authenticator 28 | func (a AuthCrowd) AuthenticatorID() string { return "crowd" } 29 | 30 | // Configure loads the configuration for the Authenticator from the 31 | // global config.yaml file which is passed as a byte-slice. 32 | // If no configuration for the Authenticator is supplied the function 33 | // needs to return the plugins.ErrProviderUnconfigured 34 | func (a *AuthCrowd) Configure(yamlSource []byte) error { 35 | envelope := struct { 36 | Providers struct { 37 | Crowd *AuthCrowd `yaml:"crowd"` 38 | } `yaml:"providers"` 39 | }{} 40 | 41 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 42 | return err 43 | } 44 | 45 | if envelope.Providers.Crowd == nil { 46 | return plugins.ErrProviderUnconfigured 47 | } 48 | 49 | a.URL = envelope.Providers.Crowd.URL 50 | a.AppName = envelope.Providers.Crowd.AppName 51 | a.AppPassword = envelope.Providers.Crowd.AppPassword 52 | 53 | if a.AppName == "" || a.AppPassword == "" { 54 | return plugins.ErrProviderUnconfigured 55 | } 56 | 57 | var err error 58 | a.crowd, err = crowd.New(a.AppName, a.AppPassword, a.URL) 59 | 60 | return err 61 | } 62 | 63 | // DetectUser is used to detect a user without a login form from 64 | // a cookie, header or other methods 65 | // If no user was detected the plugins.ErrNoValidUserFound needs to be 66 | // returned 67 | func (a AuthCrowd) DetectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { 68 | cc, err := a.crowd.GetCookieConfig() 69 | if err != nil { 70 | return "", nil, err 71 | } 72 | 73 | cookie, err := r.Cookie(cc.Name) 74 | switch err { 75 | case nil: 76 | // Fine, we do have a cookie 77 | case http.ErrNoCookie: 78 | // Also fine, there is no cookie 79 | return "", nil, plugins.ErrNoValidUserFound 80 | default: 81 | return "", nil, err 82 | } 83 | 84 | ssoToken := cookie.Value 85 | sess, err := a.crowd.GetSession(ssoToken) 86 | if err != nil { 87 | log.WithError(err).Debug("Getting crowd session failed") 88 | return "", nil, plugins.ErrNoValidUserFound 89 | } 90 | 91 | user := sess.User.UserName 92 | cGroups, err := a.crowd.GetDirectGroups(user) 93 | if err != nil { 94 | return "", nil, err 95 | } 96 | 97 | groups := []string{} 98 | for _, g := range cGroups { 99 | groups = append(groups, g.Name) 100 | } 101 | 102 | return user, groups, nil 103 | } 104 | 105 | // Login is called when the user submits the login form and needs 106 | // to authenticate the user or throw an error. If the user has 107 | // successfully logged in the persistent cookie should be written 108 | // in order to use DetectUser for the next login. 109 | // If the user did not login correctly the plugins.ErrNoValidUserFound 110 | // needs to be returned 111 | func (a AuthCrowd) Login(res http.ResponseWriter, r *http.Request) (string, []plugins.MFAConfig, error) { 112 | username := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "username"}, "-")) 113 | password := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "password"}, "-")) 114 | 115 | cc, err := a.crowd.GetCookieConfig() 116 | if err != nil { 117 | return "", nil, err 118 | } 119 | 120 | sess, err := a.crowd.NewSession(username, password, r.RemoteAddr) 121 | if err != nil { 122 | log.WithFields(log.Fields{ 123 | "username": username, 124 | }).WithError(err).Debug("Crowd authentication failed") 125 | return "", nil, plugins.ErrNoValidUserFound 126 | } 127 | 128 | http.SetCookie(res, &http.Cookie{ 129 | Name: cc.Name, 130 | Value: sess.Token, 131 | Path: "/", 132 | Domain: cc.Domain, 133 | Expires: sess.Expires, 134 | Secure: cc.Secure, 135 | HttpOnly: true, 136 | }) 137 | 138 | return username, nil, nil 139 | } 140 | 141 | // LoginFields needs to return the fields required for this login 142 | // method. If no login using this method is possible the function 143 | // needs to return nil. 144 | func (a AuthCrowd) LoginFields() (fields []plugins.LoginField) { 145 | return []plugins.LoginField{ 146 | { 147 | Label: "Username", 148 | Name: "username", 149 | Placeholder: "Username", 150 | Type: "text", 151 | }, 152 | { 153 | Label: "Password", 154 | Name: "password", 155 | Placeholder: "****", 156 | Type: "password", 157 | }, 158 | } 159 | } 160 | 161 | // Logout is called when the user visits the logout endpoint and 162 | // needs to destroy any persistent stored cookies 163 | func (a AuthCrowd) Logout(res http.ResponseWriter, r *http.Request) (err error) { 164 | cc, err := a.crowd.GetCookieConfig() 165 | if err != nil { 166 | return err 167 | } 168 | 169 | http.SetCookie(res, &http.Cookie{ 170 | Name: cc.Name, 171 | Value: "", 172 | Path: "/", 173 | Domain: cc.Domain, 174 | MaxAge: -1, 175 | Secure: cc.Secure, 176 | HttpOnly: true, 177 | }) 178 | 179 | return nil 180 | } 181 | 182 | // SupportsMFA returns the MFA detection capabilities of the login 183 | // provider. If the provider can provide mfaConfig objects from its 184 | // configuration return true. If this is true the login interface 185 | // will display an additional field for this provider for the user 186 | // to fill in their MFA token. 187 | func (a AuthCrowd) SupportsMFA() bool { return false } 188 | -------------------------------------------------------------------------------- /plugins/auth/google/auth.go: -------------------------------------------------------------------------------- 1 | package google 2 | 3 | import ( 4 | "context" 5 | "encoding/gob" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "golang.org/x/oauth2" 11 | "golang.org/x/oauth2/google" 12 | v2 "google.golang.org/api/oauth2/v2" 13 | "google.golang.org/api/option" 14 | yaml "gopkg.in/yaml.v3" 15 | 16 | "github.com/gorilla/sessions" 17 | "github.com/pkg/errors" 18 | 19 | "github.com/Luzifer/go_helpers/v2/str" 20 | "github.com/Luzifer/nginx-sso/plugins" 21 | ) 22 | 23 | const ( 24 | userIDMethodFullEmail = "full-email" 25 | userIDMethodLocalPart = "local-part" 26 | userIDMethodUserID = "user-id" 27 | ) 28 | 29 | type AuthGoogleOAuth struct { 30 | ClientID string `yaml:"client_id"` 31 | ClientSecret string `yaml:"client_secret"` 32 | RedirectURL string `yaml:"redirect_url"` 33 | 34 | RequireDomain string `yaml:"require_domain"` // Deprecated: Use RequireDomains 35 | RequireDomains []string `yaml:"require_domains"` 36 | UserIDMethod string `yaml:"user_id_method"` 37 | 38 | cookie plugins.CookieConfig 39 | cookieStore *sessions.CookieStore 40 | } 41 | 42 | func init() { 43 | gob.Register(&oauth2.Token{}) 44 | } 45 | 46 | func New(cs *sessions.CookieStore) *AuthGoogleOAuth { 47 | return &AuthGoogleOAuth{ 48 | UserIDMethod: userIDMethodUserID, 49 | cookieStore: cs, 50 | } 51 | } 52 | 53 | // AuthenticatorID needs to return an unique string to identify 54 | // this special authenticator 55 | func (a *AuthGoogleOAuth) AuthenticatorID() (id string) { return "google_oauth" } 56 | 57 | // Configure loads the configuration for the Authenticator from the 58 | // global config.yaml file which is passed as a byte-slice. 59 | // If no configuration for the Authenticator is supplied the function 60 | // needs to return the ErrProviderUnconfigured 61 | func (a *AuthGoogleOAuth) Configure(yamlSource []byte) (err error) { 62 | envelope := struct { 63 | Cookie plugins.CookieConfig `yaml:"cookie"` 64 | Providers struct { 65 | GoogleOAuth *AuthGoogleOAuth `yaml:"google_oauth"` 66 | } `yaml:"providers"` 67 | }{} 68 | 69 | envelope.Cookie = plugins.DefaultCookieConfig() 70 | 71 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 72 | return err 73 | } 74 | 75 | if envelope.Providers.GoogleOAuth == nil { 76 | return plugins.ErrProviderUnconfigured 77 | } 78 | 79 | a.ClientID = envelope.Providers.GoogleOAuth.ClientID 80 | a.ClientSecret = envelope.Providers.GoogleOAuth.ClientSecret 81 | a.RedirectURL = envelope.Providers.GoogleOAuth.RedirectURL 82 | a.RequireDomains = envelope.Providers.GoogleOAuth.RequireDomains 83 | 84 | if len(envelope.Providers.GoogleOAuth.RequireDomain) > 0 { 85 | // Migration for old configuration with only single require_domain 86 | a.RequireDomains = append( 87 | a.RequireDomains, 88 | envelope.Providers.GoogleOAuth.RequireDomain, 89 | ) 90 | } 91 | 92 | if envelope.Providers.GoogleOAuth.UserIDMethod != "" { 93 | a.UserIDMethod = envelope.Providers.GoogleOAuth.UserIDMethod 94 | } 95 | 96 | a.cookie = envelope.Cookie 97 | 98 | return nil 99 | } 100 | 101 | // DetectUser is used to detect a user without a login form from 102 | // a cookie, header or other methods 103 | // If no user was detected the ErrNoValidUserFound needs to be 104 | // returned 105 | func (a *AuthGoogleOAuth) DetectUser(res http.ResponseWriter, r *http.Request) (user string, groups []string, err error) { 106 | sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) 107 | if err != nil { 108 | return "", nil, plugins.ErrNoValidUserFound 109 | } 110 | 111 | token, ok := sess.Values["google_token"].(*oauth2.Token) 112 | if !ok { 113 | return "", nil, plugins.ErrNoValidUserFound 114 | } 115 | 116 | u, err := a.getUserFromToken(r.Context(), token) 117 | if err != nil { 118 | if err == plugins.ErrNoValidUserFound { 119 | return "", nil, err 120 | } 121 | return "", nil, errors.Wrap(err, "Unable to fetch user info") 122 | } 123 | 124 | // We had a cookie, lets renew it 125 | sess.Options = a.cookie.GetSessionOpts() 126 | if err := sess.Save(r, res); err != nil { 127 | return "", nil, err 128 | } 129 | 130 | return u, nil, nil // TODO: Maybe get group info? 131 | } 132 | 133 | // Login is called when the user submits the login form and needs 134 | // to authenticate the user or throw an error. If the user has 135 | // successfully logged in the persistent cookie should be written 136 | // in order to use DetectUser for the next login. 137 | // With the login result an array of mfaConfig must be returned. In 138 | // case there is no MFA config or the provider does not support MFA 139 | // return nil. 140 | // If the user did not login correctly the ErrNoValidUserFound 141 | // needs to be returned 142 | func (a *AuthGoogleOAuth) Login(res http.ResponseWriter, r *http.Request) (user string, mfaConfigs []plugins.MFAConfig, err error) { 143 | var ( 144 | code = r.URL.Query().Get("code") 145 | state = r.URL.Query().Get("state") 146 | u string 147 | ) 148 | 149 | if code == "" || state != a.AuthenticatorID() { 150 | return "", nil, plugins.ErrNoValidUserFound 151 | } 152 | 153 | token, err := a.getOAuthConfig().Exchange(r.Context(), code) 154 | if err != nil { 155 | return "", nil, errors.Wrap(err, "Unable to exchange token") 156 | } 157 | 158 | u, err = a.getUserFromToken(r.Context(), token) 159 | if err != nil { 160 | if err == plugins.ErrNoValidUserFound { 161 | return "", nil, err 162 | } 163 | return "", nil, errors.Wrap(err, "Unable to fetch user info") 164 | } 165 | 166 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 167 | sess.Options = a.cookie.GetSessionOpts() 168 | sess.Values["google_token"] = token 169 | 170 | return u, nil, sess.Save(r, res) 171 | } 172 | 173 | // LoginFields needs to return the fields required for this login 174 | // method. If no login using this method is possible the function 175 | // needs to return nil. 176 | func (a *AuthGoogleOAuth) LoginFields() (fields []plugins.LoginField) { 177 | loginURL := a.getOAuthConfig().AuthCodeURL( 178 | a.AuthenticatorID(), 179 | oauth2.AccessTypeOffline, 180 | oauth2.SetAuthURLParam("prompt", "consent"), 181 | ) 182 | 183 | return []plugins.LoginField{ 184 | { 185 | Action: fmt.Sprintf("window.location.href='%s'", loginURL), 186 | Label: "Trigger Login", 187 | Name: "button", 188 | Placeholder: "Sign in with Google", 189 | Type: "button", 190 | }, 191 | } 192 | } 193 | 194 | // Logout is called when the user visits the logout endpoint and 195 | // needs to destroy any persistent stored cookies 196 | func (a *AuthGoogleOAuth) Logout(res http.ResponseWriter, r *http.Request) (err error) { 197 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 198 | sess.Options = a.cookie.GetSessionOpts() 199 | sess.Options.MaxAge = -1 // Instant delete 200 | return sess.Save(r, res) 201 | } 202 | 203 | // SupportsMFA returns the MFA detection capabilities of the login 204 | // provider. If the provider can provide mfaConfig objects from its 205 | // configuration return true. If this is true the login interface 206 | // will display an additional field for this provider for the user 207 | // to fill in their MFA token. 208 | func (a *AuthGoogleOAuth) SupportsMFA() bool { return false } 209 | 210 | func (a *AuthGoogleOAuth) getOAuthConfig() *oauth2.Config { 211 | return &oauth2.Config{ 212 | ClientID: a.ClientID, 213 | ClientSecret: a.ClientSecret, 214 | Endpoint: google.Endpoint, 215 | RedirectURL: a.RedirectURL, 216 | Scopes: []string{ 217 | v2.UserinfoEmailScope, 218 | v2.UserinfoProfileScope, 219 | }, 220 | } 221 | } 222 | 223 | func (a *AuthGoogleOAuth) getUserFromToken(ctx context.Context, token *oauth2.Token) (string, error) { 224 | conf := a.getOAuthConfig() 225 | 226 | httpClient := conf.Client(ctx, token) 227 | client, err := v2.NewService(ctx, option.WithHTTPClient(httpClient)) 228 | if err != nil { 229 | return "", errors.Wrap(err, "Unable to instantiate OAuth2 API service") 230 | } 231 | 232 | tok, err := client.Tokeninfo().Context(ctx).Do() 233 | if err != nil { 234 | return "", errors.Wrap(err, "Unable to fetch token-info") 235 | } 236 | 237 | var mailParts = strings.Split(tok.Email, "@") 238 | if len(mailParts) != 2 { 239 | return "", errors.New("Invalid email returned") 240 | } 241 | 242 | if len(a.RequireDomains) > 0 && !str.StringInSlice(mailParts[1], a.RequireDomains) { 243 | // E-Mail domain is enforced, ignore all other users 244 | return "", plugins.ErrNoValidUserFound 245 | } 246 | 247 | switch a.UserIDMethod { 248 | case userIDMethodFullEmail: 249 | return tok.Email, nil 250 | 251 | case userIDMethodLocalPart: 252 | return strings.Split(tok.Email, "@")[0], nil 253 | 254 | case "": 255 | fallthrough 256 | case userIDMethodUserID: 257 | return tok.UserId, nil 258 | 259 | default: 260 | return "", errors.Errorf("Invalid user_id_method %q", a.UserIDMethod) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /plugins/auth/ldap/auth.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | ldap "gopkg.in/ldap.v2" 11 | yaml "gopkg.in/yaml.v3" 12 | 13 | "github.com/gorilla/sessions" 14 | 15 | "github.com/Luzifer/nginx-sso/plugins" 16 | ) 17 | 18 | const ( 19 | authLDAPProtoLDAP = "ldap" 20 | authLDAPProtoLDAPs = "ldaps" 21 | ) 22 | 23 | type AuthLDAP struct { 24 | EnableBasicAuth bool `yaml:"enable_basic_auth"` 25 | GroupMembershipFilter string `yaml:"group_membership_filter"` 26 | GroupSearchBase string `yaml:"group_search_base"` 27 | ManagerDN string `yaml:"manager_dn"` 28 | ManagerPassword string `yaml:"manager_password"` 29 | RootDN string `yaml:"root_dn"` 30 | Server string `yaml:"server"` 31 | UserSearchBase string `yaml:"user_search_base"` 32 | UserSearchFilter string `yaml:"user_search_filter"` 33 | UsernameAttribute string `yaml:"username_attribute"` 34 | TLSConfig *struct { 35 | ValidateHostname string `yaml:"validate_hostname"` 36 | AllowInsecure bool `yaml:"allow_insecure"` 37 | } `yaml:"tls_config"` 38 | 39 | cookie plugins.CookieConfig 40 | cookieStore *sessions.CookieStore 41 | } 42 | 43 | func New(cs *sessions.CookieStore) *AuthLDAP { 44 | return &AuthLDAP{ 45 | cookieStore: cs, 46 | } 47 | } 48 | 49 | // AuthenticatorID needs to return an unique string to identify 50 | // this special authenticator 51 | func (a AuthLDAP) AuthenticatorID() string { return "ldap" } 52 | 53 | // Configure loads the configuration for the Authenticator from the 54 | // global config.yaml file which is passed as a byte-slice. 55 | // If no configuration for the Authenticator is supplied the function 56 | // needs to return the plugins.ErrProviderUnconfigured 57 | func (a *AuthLDAP) Configure(yamlSource []byte) error { 58 | envelope := struct { 59 | Cookie plugins.CookieConfig `yaml:"cookie"` 60 | Providers struct { 61 | LDAP *AuthLDAP `yaml:"ldap"` 62 | } `yaml:"providers"` 63 | }{} 64 | 65 | envelope.Cookie = plugins.DefaultCookieConfig() 66 | 67 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 68 | return err 69 | } 70 | 71 | if envelope.Providers.LDAP == nil { 72 | return plugins.ErrProviderUnconfigured 73 | } 74 | 75 | a.EnableBasicAuth = envelope.Providers.LDAP.EnableBasicAuth 76 | a.GroupMembershipFilter = envelope.Providers.LDAP.GroupMembershipFilter 77 | a.GroupSearchBase = envelope.Providers.LDAP.GroupSearchBase 78 | a.ManagerDN = envelope.Providers.LDAP.ManagerDN 79 | a.ManagerPassword = envelope.Providers.LDAP.ManagerPassword 80 | a.RootDN = envelope.Providers.LDAP.RootDN 81 | a.Server = envelope.Providers.LDAP.Server 82 | a.UserSearchBase = envelope.Providers.LDAP.UserSearchBase 83 | a.UserSearchFilter = envelope.Providers.LDAP.UserSearchFilter 84 | a.UsernameAttribute = envelope.Providers.LDAP.UsernameAttribute 85 | a.TLSConfig = envelope.Providers.LDAP.TLSConfig 86 | 87 | a.cookie = envelope.Cookie 88 | 89 | // Set defaults 90 | if a.UserSearchFilter == "" { 91 | a.UserSearchFilter = `(uid={0})` 92 | } 93 | if a.GroupMembershipFilter == "" { 94 | a.GroupMembershipFilter = `(|(member={0})(uniqueMember={0}))` 95 | } 96 | if a.UserSearchBase == "" { 97 | a.UserSearchBase = a.RootDN 98 | } 99 | 100 | if a.GroupSearchBase == "" { 101 | a.GroupSearchBase = a.RootDN 102 | } 103 | 104 | if a.UsernameAttribute == "" { 105 | a.UsernameAttribute = "dn" 106 | } 107 | 108 | return nil 109 | } 110 | 111 | // DetectUser is used to detect a user without a login form from 112 | // a cookie, header or other methods 113 | // If no user was detected the plugins.ErrNoValidUserFound needs to be 114 | // returned 115 | func (a AuthLDAP) DetectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { 116 | var alias, user string 117 | 118 | if a.EnableBasicAuth { 119 | if basicUser, basicPass, ok := r.BasicAuth(); ok { 120 | userDN, a, err := a.checkLogin(basicUser, basicPass, a.UsernameAttribute) 121 | if err != nil { 122 | return "", nil, err 123 | } 124 | 125 | user = userDN 126 | alias = a 127 | } 128 | } 129 | 130 | if user == "" { 131 | sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) 132 | if err != nil { 133 | return "", nil, plugins.ErrNoValidUserFound 134 | } 135 | 136 | var ok bool 137 | if user, ok = sess.Values["user"].(string); !ok { 138 | return "", nil, plugins.ErrNoValidUserFound 139 | } 140 | 141 | if alias, ok = sess.Values["alias"].(string); !ok { 142 | // Most likely an old cookie, force re-login 143 | return "", nil, plugins.ErrNoValidUserFound 144 | } 145 | 146 | // We had a cookie, lets renew it 147 | sess.Options = a.cookie.GetSessionOpts() 148 | if err := sess.Save(r, res); err != nil { 149 | return "", nil, err 150 | } 151 | } 152 | 153 | groups, err := a.getUserGroups(user, alias) 154 | 155 | return alias, groups, err 156 | } 157 | 158 | // Login is called when the user submits the login form and needs 159 | // to authenticate the user or throw an error. If the user has 160 | // successfully logged in the persistent cookie should be written 161 | // in order to use DetectUser for the next login. 162 | // If the user did not login correctly the plugins.ErrNoValidUserFound 163 | // needs to be returned 164 | func (a AuthLDAP) Login(res http.ResponseWriter, r *http.Request) (string, []plugins.MFAConfig, error) { 165 | username := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "username"}, "-")) 166 | password := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "password"}, "-")) 167 | 168 | var ( 169 | userDN string 170 | alias string 171 | err error 172 | ) 173 | 174 | if userDN, alias, err = a.checkLogin(username, password, a.UsernameAttribute); err != nil { 175 | return "", nil, err 176 | } 177 | 178 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 179 | sess.Options = a.cookie.GetSessionOpts() 180 | sess.Values["user"] = userDN 181 | sess.Values["alias"] = alias 182 | return userDN, nil, sess.Save(r, res) 183 | } 184 | 185 | // LoginFields needs to return the fields required for this login 186 | // method. If no login using this method is possible the function 187 | // needs to return nil. 188 | func (a AuthLDAP) LoginFields() (fields []plugins.LoginField) { 189 | return []plugins.LoginField{ 190 | { 191 | Label: "Username", 192 | Name: "username", 193 | Placeholder: "Username", 194 | Type: "text", 195 | }, 196 | { 197 | Label: "Password", 198 | Name: "password", 199 | Placeholder: "****", 200 | Type: "password", 201 | }, 202 | } 203 | } 204 | 205 | // Logout is called when the user visits the logout endpoint and 206 | // needs to destroy any persistent stored cookies 207 | func (a AuthLDAP) Logout(res http.ResponseWriter, r *http.Request) (err error) { 208 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 209 | sess.Options = a.cookie.GetSessionOpts() 210 | sess.Options.MaxAge = -1 // Instant delete 211 | return sess.Save(r, res) 212 | } 213 | 214 | // checkLogin searches for the username using the specified UserSearchFilter 215 | // and returns the UserDN and an error (plugins.ErrNoValidUserFound / processing error) 216 | func (a AuthLDAP) checkLogin(username, password, aliasAttribute string) (string, string, error) { 217 | l, err := a.dial() 218 | if err != nil { 219 | return "", "", err 220 | } 221 | defer l.Close() 222 | 223 | sreq := ldap.NewSearchRequest( 224 | a.UserSearchBase, 225 | ldap.ScopeWholeSubtree, 226 | ldap.NeverDerefAliases, 227 | 0, 0, false, 228 | strings.Replace(a.UserSearchFilter, `{0}`, username, -1), 229 | []string{"dn", aliasAttribute}, 230 | nil, 231 | ) 232 | 233 | sres, err := l.Search(sreq) 234 | if err != nil { 235 | return "", "", fmt.Errorf("Unable to search for user: %s", err) 236 | } 237 | 238 | if len(sres.Entries) != 1 { 239 | return "", "", plugins.ErrNoValidUserFound 240 | } 241 | 242 | userDN := sres.Entries[0].DN 243 | 244 | if err := l.Bind(userDN, password); err != nil { 245 | return "", "", plugins.ErrNoValidUserFound 246 | } 247 | 248 | alias := sres.Entries[0].GetAttributeValue(aliasAttribute) 249 | if aliasAttribute == "dn" { 250 | // DN is not fetchable through GetAttributeValue as it is not an attribute 251 | alias = userDN 252 | } 253 | 254 | return userDN, alias, nil 255 | } 256 | 257 | func (a AuthLDAP) portFromScheme(scheme, override string) string { 258 | if override != "" { 259 | return override 260 | } 261 | 262 | switch scheme { 263 | case authLDAPProtoLDAP: 264 | return "389" 265 | case authLDAPProtoLDAPs: 266 | return "636" 267 | default: 268 | return "" 269 | } 270 | } 271 | 272 | // dial connects to the LDAP server and authenticates using manager_dn 273 | func (a AuthLDAP) dial() (*ldap.Conn, error) { 274 | u, err := url.Parse(a.Server) 275 | if err != nil { 276 | return nil, err 277 | } 278 | 279 | host := u.Hostname() 280 | port := u.Port() 281 | 282 | var l *ldap.Conn 283 | 284 | switch u.Scheme { 285 | case authLDAPProtoLDAP: 286 | l, err = ldap.Dial("tcp", fmt.Sprintf("%s:%s", host, a.portFromScheme(u.Scheme, port))) 287 | 288 | case authLDAPProtoLDAPs: 289 | tlsConfig := &tls.Config{ServerName: host} 290 | 291 | if a.TLSConfig != nil && (a.TLSConfig.ValidateHostname != "" || a.TLSConfig.AllowInsecure) { 292 | // #nosec G402 - InsecureSkipVerify is required for internal certs 293 | tlsConfig = &tls.Config{ 294 | ServerName: a.TLSConfig.ValidateHostname, 295 | InsecureSkipVerify: a.TLSConfig.AllowInsecure, 296 | } 297 | } 298 | 299 | l, err = ldap.DialTLS( 300 | "tcp", fmt.Sprintf("%s:%s", host, a.portFromScheme(u.Scheme, port)), 301 | tlsConfig, 302 | ) 303 | 304 | default: 305 | return nil, fmt.Errorf("Unsupported scheme %s", u.Scheme) 306 | } 307 | 308 | if err != nil { 309 | return nil, fmt.Errorf("Unable to connect to LDAP: %s", err) 310 | } 311 | 312 | if err = l.Bind(a.ManagerDN, a.ManagerPassword); err != nil { 313 | return nil, fmt.Errorf("Unable to authenticate with manager_dn: %s", err) 314 | } 315 | 316 | return l, err 317 | } 318 | 319 | // getUserGroups searches for groups containing the user 320 | func (a AuthLDAP) getUserGroups(userDN, alias string) ([]string, error) { 321 | l, err := a.dial() 322 | if err != nil { 323 | return nil, err 324 | } 325 | defer l.Close() 326 | 327 | sreq := ldap.NewSearchRequest( 328 | a.GroupSearchBase, 329 | ldap.ScopeWholeSubtree, 330 | ldap.NeverDerefAliases, 331 | 0, 0, false, 332 | strings.NewReplacer( 333 | `{0}`, userDN, 334 | `{1}`, alias, 335 | ).Replace(a.GroupMembershipFilter), 336 | []string{"dn"}, 337 | nil, 338 | ) 339 | 340 | sres, err := l.Search(sreq) 341 | if err != nil { 342 | return nil, fmt.Errorf("Unable to search for groups: %s", err) 343 | } 344 | 345 | groups := []string{} 346 | for _, r := range sres.Entries { 347 | groups = append(groups, r.DN) 348 | } 349 | 350 | return groups, nil 351 | } 352 | 353 | // SupportsMFA returns the MFA detection capabilities of the login 354 | // provider. If the provider can provide mfaConfig objects from its 355 | // configuration return true. If this is true the login interface 356 | // will display an additional field for this provider for the user 357 | // to fill in their MFA token. 358 | func (a AuthLDAP) SupportsMFA() bool { return false } // TODO: Implement 359 | -------------------------------------------------------------------------------- /plugins/auth/oidc/auth.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "context" 5 | "encoding/gob" 6 | "fmt" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | 11 | "golang.org/x/oauth2" 12 | yaml "gopkg.in/yaml.v3" 13 | 14 | "github.com/coreos/go-oidc/v3/oidc" 15 | "github.com/gorilla/sessions" 16 | "github.com/pkg/errors" 17 | 18 | "github.com/Luzifer/nginx-sso/plugins" 19 | ) 20 | 21 | const ( 22 | userIDMethodFullEmail = "full-email" 23 | userIDMethodLocalPart = "local-part" 24 | userIDMethodSubject = "subject" 25 | ) 26 | 27 | var http4xxErrorResponse = regexp.MustCompile(`^(4[0-9]{2}) (.*)`) 28 | 29 | type AuthOIDC struct { 30 | ClientID string `yaml:"client_id"` 31 | ClientSecret string `yaml:"client_secret"` 32 | IssuerName string `yaml:"issuer_name"` 33 | IssuerURL string `yaml:"issuer_url"` 34 | RedirectURL string `yaml:"redirect_url"` 35 | 36 | RequireDomain string `yaml:"require_domain"` 37 | UserIDMethod string `yaml:"user_id_method"` 38 | 39 | cookie plugins.CookieConfig 40 | cookieStore *sessions.CookieStore 41 | 42 | provider *oidc.Provider 43 | } 44 | 45 | func init() { 46 | gob.Register(&oauth2.Token{}) 47 | } 48 | 49 | func New(cs *sessions.CookieStore) *AuthOIDC { 50 | return &AuthOIDC{ 51 | IssuerName: "OpenID Connect", 52 | UserIDMethod: userIDMethodSubject, 53 | cookieStore: cs, 54 | } 55 | } 56 | 57 | // AuthenticatorID needs to return an unique string to identify 58 | // this special authenticator 59 | func (a *AuthOIDC) AuthenticatorID() (id string) { return "oidc" } 60 | 61 | // Configure loads the configuration for the Authenticator from the 62 | // global config.yaml file which is passed as a byte-slice. 63 | // If no configuration for the Authenticator is supplied the function 64 | // needs to return the ErrProviderUnconfigured 65 | func (a *AuthOIDC) Configure(yamlSource []byte) (err error) { 66 | envelope := struct { 67 | Cookie plugins.CookieConfig `yaml:"cookie"` 68 | Providers struct { 69 | OIDC *AuthOIDC `yaml:"oidc"` 70 | } `yaml:"providers"` 71 | }{} 72 | 73 | envelope.Cookie = plugins.DefaultCookieConfig() 74 | 75 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 76 | return err 77 | } 78 | 79 | if envelope.Providers.OIDC == nil { 80 | return plugins.ErrProviderUnconfigured 81 | } 82 | 83 | a.ClientID = envelope.Providers.OIDC.ClientID 84 | a.ClientSecret = envelope.Providers.OIDC.ClientSecret 85 | a.IssuerURL = envelope.Providers.OIDC.IssuerURL 86 | a.RedirectURL = envelope.Providers.OIDC.RedirectURL 87 | a.RequireDomain = envelope.Providers.OIDC.RequireDomain 88 | 89 | if envelope.Providers.OIDC.IssuerName != "" { 90 | a.IssuerName = envelope.Providers.OIDC.IssuerName 91 | } 92 | 93 | if envelope.Providers.OIDC.UserIDMethod != "" { 94 | a.UserIDMethod = envelope.Providers.OIDC.UserIDMethod 95 | } 96 | 97 | a.cookie = envelope.Cookie 98 | 99 | provider, err := oidc.NewProvider(context.Background(), a.IssuerURL) 100 | if err != nil { 101 | return errors.Wrap(err, "Unable to fetch provider configuration") 102 | } 103 | a.provider = provider 104 | 105 | return nil 106 | } 107 | 108 | // DetectUser is used to detect a user without a login form from 109 | // a cookie, header or other methods 110 | // If no user was detected the ErrNoValidUserFound needs to be 111 | // returned 112 | func (a *AuthOIDC) DetectUser(res http.ResponseWriter, r *http.Request) (user string, groups []string, err error) { 113 | sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) 114 | if err != nil { 115 | return "", nil, plugins.ErrNoValidUserFound 116 | } 117 | 118 | token, ok := sess.Values["oauth_token"].(*oauth2.Token) 119 | if !ok { 120 | return "", nil, plugins.ErrNoValidUserFound 121 | } 122 | 123 | u, err := a.getUserFromToken(r.Context(), token) 124 | if err != nil { 125 | if err == plugins.ErrNoValidUserFound { 126 | return "", nil, err 127 | } 128 | return "", nil, errors.Wrap(err, "Unable to fetch user info") 129 | } 130 | 131 | // We had a cookie, lets renew it 132 | sess.Options = a.cookie.GetSessionOpts() 133 | if err := sess.Save(r, res); err != nil { 134 | return "", nil, err 135 | } 136 | 137 | return u, nil, nil // TODO: Maybe get group info? 138 | } 139 | 140 | // Login is called when the user submits the login form and needs 141 | // to authenticate the user or throw an error. If the user has 142 | // successfully logged in the persistent cookie should be written 143 | // in order to use DetectUser for the next login. 144 | // With the login result an array of mfaConfig must be returned. In 145 | // case there is no MFA config or the provider does not support MFA 146 | // return nil. 147 | // If the user did not login correctly the ErrNoValidUserFound 148 | // needs to be returned 149 | func (a *AuthOIDC) Login(res http.ResponseWriter, r *http.Request) (user string, mfaConfigs []plugins.MFAConfig, err error) { 150 | var ( 151 | code = r.URL.Query().Get("code") 152 | state = r.URL.Query().Get("state") 153 | u string 154 | ) 155 | 156 | if code == "" || state != a.AuthenticatorID() { 157 | return "", nil, plugins.ErrNoValidUserFound 158 | } 159 | 160 | token, err := a.getOAuthConfig().Exchange(r.Context(), code) 161 | if err != nil { 162 | return "", nil, errors.Wrap(err, "Unable to exchange token") 163 | } 164 | 165 | u, err = a.getUserFromToken(r.Context(), token) 166 | if err != nil { 167 | if err == plugins.ErrNoValidUserFound { 168 | return "", nil, err 169 | } 170 | return "", nil, errors.Wrap(err, "Unable to fetch user info") 171 | } 172 | 173 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 174 | sess.Options = a.cookie.GetSessionOpts() 175 | sess.Values["oauth_token"] = token 176 | 177 | return u, nil, sess.Save(r, res) 178 | } 179 | 180 | // LoginFields needs to return the fields required for this login 181 | // method. If no login using this method is possible the function 182 | // needs to return nil. 183 | func (a *AuthOIDC) LoginFields() (fields []plugins.LoginField) { 184 | loginURL := a.getOAuthConfig().AuthCodeURL(a.AuthenticatorID()) 185 | 186 | return []plugins.LoginField{ 187 | { 188 | Action: fmt.Sprintf("window.location.href='%s'", loginURL), 189 | Label: "Trigger Login", 190 | Name: "button", 191 | Placeholder: fmt.Sprintf("Sign in with %s", a.IssuerName), 192 | Type: "button", 193 | }, 194 | } 195 | } 196 | 197 | // Logout is called when the user visits the logout endpoint and 198 | // needs to destroy any persistent stored cookies 199 | func (a *AuthOIDC) Logout(res http.ResponseWriter, r *http.Request) (err error) { 200 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 201 | sess.Options = a.cookie.GetSessionOpts() 202 | sess.Options.MaxAge = -1 // Instant delete 203 | return sess.Save(r, res) 204 | } 205 | 206 | // SupportsMFA returns the MFA detection capabilities of the login 207 | // provider. If the provider can provide mfaConfig objects from its 208 | // configuration return true. If this is true the login interface 209 | // will display an additional field for this provider for the user 210 | // to fill in their MFA token. 211 | func (a *AuthOIDC) SupportsMFA() bool { return false } 212 | 213 | func (a *AuthOIDC) getOAuthConfig() *oauth2.Config { 214 | return &oauth2.Config{ 215 | ClientID: a.ClientID, 216 | ClientSecret: a.ClientSecret, 217 | Endpoint: a.provider.Endpoint(), 218 | RedirectURL: a.RedirectURL, 219 | Scopes: []string{ 220 | oidc.ScopeOpenID, 221 | "profile", 222 | "email", 223 | }, 224 | } 225 | } 226 | 227 | func (a *AuthOIDC) getUserFromToken(ctx context.Context, token *oauth2.Token) (string, error) { 228 | ui, err := a.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) 229 | if err != nil { 230 | if http4xxErrorResponse.MatchString(err.Error()) { 231 | /* 232 | * Server answered with any 4xx error 233 | * 234 | * Google OIDC: 401 Unauthorized => Token expired 235 | * Wordpress OIDC plugin: 400 Bad Request => Token expired 236 | * 237 | * As long as they can't agree on ONE status for that we need to 238 | * handle all 4xx as "token expired" and therefore "no valid user" 239 | */ 240 | return "", plugins.ErrNoValidUserFound 241 | } 242 | 243 | // Other error: Report the error 244 | return "", errors.Wrap(err, "Unable to fetch user info") 245 | } 246 | 247 | if a.RequireDomain != "" && !strings.HasSuffix(ui.Email, "@"+a.RequireDomain) { 248 | // E-Mail domain is enforced, ignore all other users 249 | return "", plugins.ErrNoValidUserFound 250 | } 251 | 252 | switch a.UserIDMethod { 253 | case userIDMethodFullEmail: 254 | return ui.Email, nil 255 | 256 | case userIDMethodLocalPart: 257 | return strings.Split(ui.Email, "@")[0], nil 258 | 259 | case "": 260 | fallthrough 261 | case userIDMethodSubject: 262 | return ui.Subject, nil 263 | 264 | default: 265 | return "", errors.Errorf("Invalid user_id_method %q", a.UserIDMethod) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /plugins/auth/simple/auth.go: -------------------------------------------------------------------------------- 1 | package simple 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | yaml "gopkg.in/yaml.v3" 9 | 10 | "github.com/gorilla/sessions" 11 | 12 | "github.com/Luzifer/go_helpers/v2/str" 13 | "github.com/Luzifer/nginx-sso/plugins" 14 | ) 15 | 16 | type AuthSimple struct { 17 | EnableBasicAuth bool `yaml:"enable_basic_auth"` 18 | Users map[string]string `yaml:"users"` 19 | Groups map[string][]string `yaml:"groups"` 20 | MFA map[string][]plugins.MFAConfig `yaml:"mfa"` 21 | 22 | cookie plugins.CookieConfig 23 | cookieStore *sessions.CookieStore 24 | } 25 | 26 | func New(cs *sessions.CookieStore) *AuthSimple { 27 | return &AuthSimple{ 28 | cookieStore: cs, 29 | } 30 | } 31 | 32 | // AuthenticatorID needs to return an unique string to identify 33 | // this special authenticator 34 | func (a AuthSimple) AuthenticatorID() string { return "simple" } 35 | 36 | // Configure loads the configuration for the Authenticator from the 37 | // global config.yaml file which is passed as a byte-slice. 38 | // If no configuration for the Authenticator is supplied the function 39 | // needs to return the plugins.ErrProviderUnconfigured 40 | func (a *AuthSimple) Configure(yamlSource []byte) error { 41 | envelope := struct { 42 | Cookie plugins.CookieConfig `yaml:"cookie"` 43 | Providers struct { 44 | Simple *AuthSimple `yaml:"simple"` 45 | } `yaml:"providers"` 46 | }{} 47 | 48 | envelope.Cookie = plugins.DefaultCookieConfig() 49 | 50 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 51 | return err 52 | } 53 | 54 | if envelope.Providers.Simple == nil { 55 | return plugins.ErrProviderUnconfigured 56 | } 57 | 58 | a.EnableBasicAuth = envelope.Providers.Simple.EnableBasicAuth 59 | a.Users = envelope.Providers.Simple.Users 60 | a.Groups = envelope.Providers.Simple.Groups 61 | a.MFA = envelope.Providers.Simple.MFA 62 | 63 | a.cookie = envelope.Cookie 64 | 65 | return nil 66 | } 67 | 68 | // DetectUser is used to detect a user without a login form from 69 | // a cookie, header or other methods 70 | // If no user was detected the plugins.ErrNoValidUserFound needs to be 71 | // returned 72 | func (a AuthSimple) DetectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { 73 | var user string 74 | 75 | if a.EnableBasicAuth { 76 | if basicUser, basicPass, ok := r.BasicAuth(); ok { 77 | for u, p := range a.Users { 78 | if u != basicUser { 79 | continue 80 | } 81 | if bcrypt.CompareHashAndPassword([]byte(p), []byte(basicPass)) != nil { 82 | continue 83 | } 84 | 85 | user = basicUser 86 | } 87 | } 88 | } 89 | 90 | if user == "" { 91 | sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) 92 | if err != nil { 93 | return "", nil, plugins.ErrNoValidUserFound 94 | } 95 | 96 | var ok bool 97 | user, ok = sess.Values["user"].(string) 98 | if !ok { 99 | return "", nil, plugins.ErrNoValidUserFound 100 | } 101 | 102 | // We had a cookie, lets renew it 103 | sess.Options = a.cookie.GetSessionOpts() 104 | if err := sess.Save(r, res); err != nil { 105 | return "", nil, err 106 | } 107 | } 108 | 109 | groups := []string{} 110 | for group, users := range a.Groups { 111 | if str.StringInSlice(user, users) { 112 | groups = append(groups, group) 113 | } 114 | } 115 | 116 | return user, groups, nil 117 | } 118 | 119 | // Login is called when the user submits the login form and needs 120 | // to authenticate the user or throw an error. If the user has 121 | // successfully logged in the persistent cookie should be written 122 | // in order to use DetectUser for the next login. 123 | // If the user did not login correctly the plugins.ErrNoValidUserFound 124 | // needs to be returned 125 | func (a AuthSimple) Login(res http.ResponseWriter, r *http.Request) (string, []plugins.MFAConfig, error) { 126 | username := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "username"}, "-")) 127 | password := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "password"}, "-")) 128 | 129 | for u, p := range a.Users { 130 | if u != username { 131 | continue 132 | } 133 | if bcrypt.CompareHashAndPassword([]byte(p), []byte(password)) != nil { 134 | continue 135 | } 136 | 137 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 138 | sess.Options = a.cookie.GetSessionOpts() 139 | sess.Values["user"] = u 140 | return u, a.MFA[u], sess.Save(r, res) 141 | } 142 | 143 | return "", nil, plugins.ErrNoValidUserFound 144 | } 145 | 146 | // LoginFields needs to return the fields required for this login 147 | // method. If no login using this method is possible the function 148 | // needs to return nil. 149 | func (a AuthSimple) LoginFields() (fields []plugins.LoginField) { 150 | return []plugins.LoginField{ 151 | { 152 | Label: "Username", 153 | Name: "username", 154 | Placeholder: "Username", 155 | Type: "text", 156 | }, 157 | { 158 | Label: "Password", 159 | Name: "password", 160 | Placeholder: "****", 161 | Type: "password", 162 | }, 163 | } 164 | } 165 | 166 | // Logout is called when the user visits the logout endpoint and 167 | // needs to destroy any persistent stored cookies 168 | func (a AuthSimple) Logout(res http.ResponseWriter, r *http.Request) (err error) { 169 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 170 | sess.Options = a.cookie.GetSessionOpts() 171 | sess.Options.MaxAge = -1 // Instant delete 172 | return sess.Save(r, res) 173 | } 174 | 175 | // SupportsMFA returns the MFA detection capabilities of the login 176 | // provider. If the provider can provide mfaConfig objects from its 177 | // configuration return true. If this is true the login interface 178 | // will display an additional field for this provider for the user 179 | // to fill in their MFA token. 180 | func (a AuthSimple) SupportsMFA() bool { return true } 181 | -------------------------------------------------------------------------------- /plugins/auth/token/auth.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/Luzifer/go_helpers/v2/str" 8 | "github.com/Luzifer/nginx-sso/plugins" 9 | 10 | yaml "gopkg.in/yaml.v3" 11 | ) 12 | 13 | type AuthToken struct { 14 | Tokens map[string]string `yaml:"tokens"` 15 | Groups map[string][]string `yaml:"groups"` 16 | } 17 | 18 | func New() *AuthToken { 19 | return &AuthToken{} 20 | } 21 | 22 | // AuthenticatorID needs to return an unique string to identify 23 | // this special authenticator 24 | func (a AuthToken) AuthenticatorID() string { return "token" } 25 | 26 | // Configure loads the configuration for the Authenticator from the 27 | // global config.yaml file which is passed as a byte-slice. 28 | // If no configuration for the Authenticator is supplied the function 29 | // needs to return the plugins.ErrProviderUnconfigured 30 | func (a *AuthToken) Configure(yamlSource []byte) error { 31 | envelope := struct { 32 | Providers struct { 33 | Token *AuthToken `yaml:"token"` 34 | } `yaml:"providers"` 35 | }{} 36 | 37 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 38 | return err 39 | } 40 | 41 | if envelope.Providers.Token == nil { 42 | return plugins.ErrProviderUnconfigured 43 | } 44 | 45 | a.Tokens = envelope.Providers.Token.Tokens 46 | a.Groups = envelope.Providers.Token.Groups 47 | 48 | return nil 49 | } 50 | 51 | // DetectUser is used to detect a user without a login form from 52 | // a cookie, header or other methods 53 | // If no user was detected the plugins.ErrNoValidUserFound needs to be 54 | // returned 55 | func (a AuthToken) DetectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { 56 | authHeader := r.Header.Get("Authorization") 57 | 58 | if !strings.HasPrefix(authHeader, "Token ") { 59 | return "", nil, plugins.ErrNoValidUserFound 60 | } 61 | 62 | tmp := strings.SplitN(authHeader, " ", 2) 63 | suppliedToken := tmp[1] 64 | 65 | var ( 66 | user, token string 67 | userFound bool 68 | ) 69 | for user, token = range a.Tokens { 70 | if token == suppliedToken { 71 | userFound = true 72 | break 73 | } 74 | } 75 | 76 | if !userFound { 77 | return "", nil, plugins.ErrNoValidUserFound 78 | } 79 | 80 | groups := []string{} 81 | for group, users := range a.Groups { 82 | if str.StringInSlice(user, users) { 83 | groups = append(groups, group) 84 | } 85 | } 86 | 87 | return user, groups, nil 88 | } 89 | 90 | // Login is called when the user submits the login form and needs 91 | // to authenticate the user or throw an error. If the user has 92 | // successfully logged in the persistent cookie should be written 93 | // in order to use DetectUser for the next login. 94 | // If the user did not login correctly the plugins.ErrNoValidUserFound 95 | // needs to be returned 96 | func (a AuthToken) Login(res http.ResponseWriter, r *http.Request) (string, []plugins.MFAConfig, error) { 97 | return "", nil, plugins.ErrNoValidUserFound 98 | } 99 | 100 | // LoginFields needs to return the fields required for this login 101 | // method. If no login using this method is possible the function 102 | // needs to return nil. 103 | func (a AuthToken) LoginFields() []plugins.LoginField { return nil } 104 | 105 | // Logout is called when the user visits the logout endpoint and 106 | // needs to destroy any persistent stored cookies 107 | func (a AuthToken) Logout(res http.ResponseWriter, r *http.Request) error { return nil } 108 | 109 | // SupportsMFA returns the MFA detection capabilities of the login 110 | // provider. If the provider can provide mfaConfig objects from its 111 | // configuration return true. If this is true the login interface 112 | // will display an additional field for this provider for the user 113 | // to fill in their MFA token. 114 | func (a AuthToken) SupportsMFA() bool { return false } 115 | -------------------------------------------------------------------------------- /plugins/auth/yubikey/auth.go: -------------------------------------------------------------------------------- 1 | package yubikey 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/GeertJohan/yubigo" 8 | "github.com/gorilla/sessions" 9 | yaml "gopkg.in/yaml.v3" 10 | 11 | "github.com/Luzifer/go_helpers/v2/str" 12 | "github.com/Luzifer/nginx-sso/plugins" 13 | ) 14 | 15 | type AuthYubikey struct { 16 | ClientID string `yaml:"client_id"` 17 | SecretKey string `yaml:"secret_key"` 18 | Devices map[string]string `yaml:"devices"` 19 | Groups map[string][]string `yaml:"groups"` 20 | 21 | cookie plugins.CookieConfig 22 | cookieStore *sessions.CookieStore 23 | } 24 | 25 | func New(cs *sessions.CookieStore) *AuthYubikey { 26 | return &AuthYubikey{ 27 | cookieStore: cs, 28 | } 29 | } 30 | 31 | // AuthenticatorID needs to return an unique string to identify 32 | // this special authenticator 33 | func (a AuthYubikey) AuthenticatorID() string { return "yubikey" } 34 | 35 | // Configure loads the configuration for the Authenticator from the 36 | // global config.yaml file which is passed as a byte-slice. 37 | // If no configuration for the Authenticator is supplied the function 38 | // needs to return the plugins.ErrProviderUnconfigured 39 | func (a *AuthYubikey) Configure(yamlSource []byte) error { 40 | envelope := struct { 41 | Cookie plugins.CookieConfig `yaml:"cookie"` 42 | Providers struct { 43 | Yubikey *AuthYubikey `yaml:"yubikey"` 44 | } `yaml:"providers"` 45 | }{} 46 | 47 | envelope.Cookie = plugins.DefaultCookieConfig() 48 | 49 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 50 | return err 51 | } 52 | 53 | if envelope.Providers.Yubikey == nil { 54 | return plugins.ErrProviderUnconfigured 55 | } 56 | 57 | a.ClientID = envelope.Providers.Yubikey.ClientID 58 | a.SecretKey = envelope.Providers.Yubikey.SecretKey 59 | a.Devices = envelope.Providers.Yubikey.Devices 60 | a.Groups = envelope.Providers.Yubikey.Groups 61 | 62 | a.cookie = envelope.Cookie 63 | 64 | return nil 65 | } 66 | 67 | // DetectUser is used to detect a user without a login form from 68 | // a cookie, header or other methods 69 | // If no user was detected the plugins.ErrNoValidUserFound needs to be 70 | // returned 71 | func (a AuthYubikey) DetectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { 72 | sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) 73 | if err != nil { 74 | return "", nil, plugins.ErrNoValidUserFound 75 | } 76 | 77 | user, ok := sess.Values["user"].(string) 78 | if !ok { 79 | return "", nil, plugins.ErrNoValidUserFound 80 | } 81 | 82 | // We had a cookie, lets renew it 83 | sess.Options = a.cookie.GetSessionOpts() 84 | if err := sess.Save(r, res); err != nil { 85 | return "", nil, err 86 | } 87 | 88 | groups := []string{} 89 | for group, users := range a.Groups { 90 | if str.StringInSlice(user, users) { 91 | groups = append(groups, group) 92 | } 93 | } 94 | 95 | return user, groups, nil 96 | } 97 | 98 | // Login is called when the user submits the login form and needs 99 | // to authenticate the user or throw an error. If the user has 100 | // successfully logged in the persistent cookie should be written 101 | // in order to use DetectUser for the next login. 102 | // If the user did not login correctly the plugins.ErrNoValidUserFound 103 | // needs to be returned 104 | func (a AuthYubikey) Login(res http.ResponseWriter, r *http.Request) (string, []plugins.MFAConfig, error) { 105 | keyInput := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "key-input"}, "-")) 106 | 107 | yubiAuth, err := yubigo.NewYubiAuth(a.ClientID, a.SecretKey) 108 | if err != nil { 109 | return "", nil, err 110 | } 111 | 112 | _, ok, err := yubiAuth.Verify(keyInput) 113 | if err != nil && !strings.Contains(err.Error(), "OTP has wrong length.") { 114 | return "", nil, err 115 | } 116 | 117 | if !ok { 118 | // Not a valid authentication 119 | return "", nil, plugins.ErrNoValidUserFound 120 | } 121 | 122 | user, ok := a.Devices[keyInput[:12]] 123 | if !ok { 124 | // We do not have a definition for that key 125 | return "", nil, plugins.ErrNoValidUserFound 126 | } 127 | 128 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 129 | sess.Options = a.cookie.GetSessionOpts() 130 | sess.Values["user"] = user 131 | return user, nil, sess.Save(r, res) 132 | } 133 | 134 | // LoginFields needs to return the fields required for this login 135 | // method. If no login using this method is possible the function 136 | // needs to return nil. 137 | func (a AuthYubikey) LoginFields() (fields []plugins.LoginField) { 138 | return []plugins.LoginField{ 139 | { 140 | Label: "Yubikey One-Time-Password", 141 | Name: "key-input", 142 | Placeholder: "Press the button of your Yubikey...", 143 | Type: "text", 144 | }, 145 | } 146 | } 147 | 148 | // Logout is called when the user visits the logout endpoint and 149 | // needs to destroy any persistent stored cookies 150 | func (a AuthYubikey) Logout(res http.ResponseWriter, r *http.Request) (err error) { 151 | sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned 152 | sess.Options = a.cookie.GetSessionOpts() 153 | sess.Options.MaxAge = -1 // Instant delete 154 | return sess.Save(r, res) 155 | } 156 | 157 | // SupportsMFA returns the MFA detection capabilities of the login 158 | // provider. If the provider can provide mfaConfig objects from its 159 | // configuration return true. If this is true the login interface 160 | // will display an additional field for this provider for the user 161 | // to fill in their MFA token. 162 | func (a AuthYubikey) SupportsMFA() bool { return false } 163 | -------------------------------------------------------------------------------- /plugins/cookie.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import "github.com/gorilla/sessions" 4 | 5 | type CookieConfig struct { 6 | Domain string `yaml:"domain"` 7 | AuthKey string `yaml:"authentication_key"` 8 | Expire int `yaml:"expire"` 9 | Prefix string `yaml:"prefix"` 10 | Secure bool `yaml:"secure"` 11 | } 12 | 13 | func (c CookieConfig) GetSessionOpts() *sessions.Options { 14 | return &sessions.Options{ 15 | Path: "/", 16 | Domain: c.Domain, 17 | MaxAge: c.Expire, 18 | Secure: c.Secure, 19 | HttpOnly: true, 20 | } 21 | } 22 | 23 | func DefaultCookieConfig() CookieConfig { 24 | return CookieConfig{ 25 | Prefix: "nginx-sso", 26 | Expire: 3600, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /plugins/errors.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrProviderUnconfigured = errors.New("No valid configuration found for this provider") 7 | ErrNoValidUserFound = errors.New("No valid users found") 8 | ) 9 | -------------------------------------------------------------------------------- /plugins/mfa.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import "net/http" 4 | 5 | const MFALoginFieldName = "mfa-token" 6 | 7 | type MFAProvider interface { 8 | // ProviderID needs to return an unique string to identify 9 | // this special MFA provider 10 | ProviderID() (id string) 11 | 12 | // Configure loads the configuration for the Authenticator from the 13 | // global config.yaml file which is passed as a byte-slice. 14 | // If no configuration for the Authenticator is supplied the function 15 | // needs to return the ErrProviderUnconfigured 16 | Configure(yamlSource []byte) (err error) 17 | 18 | // ValidateMFA takes the user from the login cookie and performs a 19 | // validation against the provided MFA configuration for this user 20 | ValidateMFA(res http.ResponseWriter, r *http.Request, user string, mfaCfgs []MFAConfig) error 21 | } 22 | 23 | type MFAConfig struct { 24 | Provider string `yaml:"provider"` 25 | Attributes map[string]interface{} `yaml:"attributes"` 26 | } 27 | 28 | func (m MFAConfig) AttributeInt(key string) int { 29 | if v, ok := m.Attributes[key]; ok && v != "" { 30 | if sv, ok := v.(int); ok { 31 | return sv 32 | } 33 | } 34 | 35 | return 0 36 | } 37 | 38 | func (m MFAConfig) AttributeString(key string) string { 39 | if v, ok := m.Attributes[key]; ok { 40 | if sv, ok := v.(string); ok { 41 | return sv 42 | } 43 | } 44 | 45 | return "" 46 | } 47 | -------------------------------------------------------------------------------- /plugins/mfa/duo/mfa.go: -------------------------------------------------------------------------------- 1 | package duo 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | duoapi "github.com/duosecurity/duo_api_golang" 10 | "github.com/duosecurity/duo_api_golang/authapi" 11 | "github.com/pkg/errors" 12 | yaml "gopkg.in/yaml.v3" 13 | 14 | "github.com/Luzifer/nginx-sso/plugins" 15 | ) 16 | 17 | const ( 18 | mfaDuoResponseAllow = "allow" 19 | mfaDuoRequestTimeout = 10 * time.Second 20 | ) 21 | 22 | var mfaDuoTrustedIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"} 23 | 24 | type MFADuo struct { 25 | IKey string `yaml:"ikey"` 26 | SKey string `yaml:"skey"` 27 | Host string `yaml:"host"` 28 | UserAgent string `yaml:"user_agent"` 29 | } 30 | 31 | func New() *MFADuo { 32 | return &MFADuo{} 33 | } 34 | 35 | // ProviderID needs to return an unique string to identify 36 | // this special MFA provider 37 | func (m MFADuo) ProviderID() (id string) { return "duo" } 38 | 39 | // Configure loads the configuration for the Authenticator from the 40 | // global config.yaml file which is passed as a byte-slice. 41 | // If no configuration for the Authenticator is supplied the function 42 | // needs to return the plugins.ErrProviderUnconfigured 43 | func (m *MFADuo) Configure(yamlSource []byte) (err error) { 44 | envelope := struct { 45 | MFA struct { 46 | Duo *MFADuo `yaml:"duo"` 47 | } `yaml:"mfa"` 48 | }{} 49 | 50 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 51 | return err 52 | } 53 | 54 | if envelope.MFA.Duo == nil { 55 | return plugins.ErrProviderUnconfigured 56 | } 57 | 58 | m.IKey = envelope.MFA.Duo.IKey 59 | m.SKey = envelope.MFA.Duo.SKey 60 | m.Host = envelope.MFA.Duo.Host 61 | m.UserAgent = envelope.MFA.Duo.UserAgent 62 | return nil 63 | } 64 | 65 | // ValidateMFA takes the user from the login cookie and performs a 66 | // validation against the provided MFA configuration for this user 67 | func (m MFADuo) ValidateMFA(res http.ResponseWriter, r *http.Request, user string, mfaCfgs []plugins.MFAConfig) error { 68 | var keyInput string 69 | 70 | // Look for mfaConfigs with own provider name 71 | for _, c := range mfaCfgs { 72 | if c.Provider != m.ProviderID() { 73 | continue 74 | } 75 | 76 | remoteIP, err := m.findIP(r) 77 | if err != nil { 78 | return errors.Wrap(err, "Unable to determine remote IP") 79 | } 80 | 81 | duo := authapi.NewAuthApi(*duoapi.NewDuoApi(m.IKey, m.SKey, m.Host, m.UserAgent, duoapi.SetTimeout(mfaDuoRequestTimeout))) 82 | 83 | for key, values := range r.Form { 84 | if strings.HasSuffix(key, plugins.MFALoginFieldName) && len(values[0]) > 0 { 85 | keyInput = values[0] 86 | } 87 | } 88 | 89 | // Check if MFA token provided and fallover to push if not supplied 90 | var auth *authapi.AuthResult 91 | 92 | if keyInput != "" { 93 | if auth, err = duo.Auth("passcode", authapi.AuthUsername(user), authapi.AuthPasscode(keyInput), authapi.AuthIpAddr(remoteIP)); err != nil { 94 | return errors.Wrap(err, "Unable to authenticate with Duo using 'passcode' method") 95 | } 96 | } else { 97 | if auth, err = duo.Auth("auto", authapi.AuthUsername(user), authapi.AuthDevice("auto"), authapi.AuthIpAddr(remoteIP)); err != nil { 98 | return errors.Wrap(err, "Unable to authenticate with Duo using 'auto' method") 99 | } 100 | } 101 | 102 | if auth.Response.Result == mfaDuoResponseAllow { 103 | return nil 104 | } 105 | } 106 | 107 | // Report this provider was not able to verify the MFA request 108 | return plugins.ErrNoValidUserFound 109 | } 110 | 111 | func (m MFADuo) findIP(r *http.Request) (string, error) { 112 | for _, hdr := range mfaDuoTrustedIPHeaders { 113 | if value := r.Header.Get(hdr); value != "" { 114 | return m.parseIP(value) 115 | } 116 | } 117 | 118 | return m.parseIP(r.RemoteAddr) 119 | } 120 | 121 | func (m MFADuo) parseIP(s string) (string, error) { 122 | ip, _, err := net.SplitHostPort(s) 123 | if err == nil { 124 | return ip, nil 125 | } 126 | 127 | ip2 := net.ParseIP(s) 128 | if ip2 == nil { 129 | return "", errors.New("invalid IP") 130 | } 131 | 132 | return ip2.String(), nil 133 | } 134 | -------------------------------------------------------------------------------- /plugins/mfa/totp/mfa.go: -------------------------------------------------------------------------------- 1 | package totp 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/pquerna/otp" 10 | "github.com/pquerna/otp/totp" 11 | 12 | "github.com/Luzifer/nginx-sso/plugins" 13 | ) 14 | 15 | type MFATOTP struct{} 16 | 17 | // ProviderID needs to return an unique string to identify 18 | // this special MFA provider 19 | func (m MFATOTP) ProviderID() (id string) { 20 | return "totp" 21 | } 22 | 23 | func New() *MFATOTP { 24 | return &MFATOTP{} 25 | } 26 | 27 | // Configure loads the configuration for the Authenticator from the 28 | // global config.yaml file which is passed as a byte-slice. 29 | // If no configuration for the Authenticator is supplied the function 30 | // needs to return the plugins.ErrProviderUnconfigured 31 | func (m MFATOTP) Configure(yamlSource []byte) (err error) { return nil } 32 | 33 | // ValidateMFA takes the user from the login cookie and performs a 34 | // validation against the provided MFA configuration for this user 35 | func (m MFATOTP) ValidateMFA(res http.ResponseWriter, r *http.Request, user string, mfaCfgs []plugins.MFAConfig) error { 36 | // Look for mfaConfigs with own provider name 37 | for _, c := range mfaCfgs { 38 | // Provider has been renamed, keep "google" for backwards compatibility 39 | if c.Provider != m.ProviderID() && c.Provider != "google" { 40 | continue 41 | } 42 | 43 | token, err := m.exec(c) 44 | if err != nil { 45 | return errors.Wrap(err, "Generating the MFA token failed") 46 | } 47 | 48 | for key, values := range r.Form { 49 | if strings.HasSuffix(key, plugins.MFALoginFieldName) && values[0] == token { 50 | return nil 51 | } 52 | } 53 | } 54 | 55 | // Report this provider was not able to verify the MFA request 56 | return plugins.ErrNoValidUserFound 57 | } 58 | 59 | func (m MFATOTP) exec(c plugins.MFAConfig) (string, error) { 60 | secret := c.AttributeString("secret") 61 | 62 | // By default use Google Authenticator compatible settings 63 | generatorOpts := totp.ValidateOpts{ 64 | Period: 30, 65 | Skew: 1, 66 | Digits: otp.DigitsSix, 67 | Algorithm: otp.AlgorithmSHA1, 68 | } 69 | 70 | if period := c.AttributeInt("period"); period > 0 { 71 | generatorOpts.Period = uint(period) 72 | } 73 | 74 | if skew := c.AttributeInt("skew"); skew > 0 { 75 | generatorOpts.Skew = uint(skew) 76 | } 77 | 78 | if digits := c.AttributeInt("digits"); digits > 0 { 79 | generatorOpts.Digits = otp.Digits(digits) 80 | } 81 | 82 | if algorithm := c.AttributeString("algorithm"); algorithm != "" { 83 | switch algorithm { 84 | case "sha1": 85 | generatorOpts.Algorithm = otp.AlgorithmSHA1 86 | case "sha256": 87 | generatorOpts.Algorithm = otp.AlgorithmSHA256 88 | case "sha512": 89 | generatorOpts.Algorithm = otp.AlgorithmSHA512 90 | default: 91 | return "", errors.Errorf("Unsupported algorithm %q", algorithm) 92 | } 93 | } 94 | 95 | if n := len(secret) % 8; n != 0 { 96 | secret += strings.Repeat("=", 8-n) 97 | } 98 | 99 | return totp.GenerateCodeCustom(strings.ToUpper(secret), time.Now(), generatorOpts) 100 | } 101 | -------------------------------------------------------------------------------- /plugins/mfa/yubikey/mfa.go: -------------------------------------------------------------------------------- 1 | package yubikey 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/GeertJohan/yubigo" 8 | "github.com/pkg/errors" 9 | yaml "gopkg.in/yaml.v3" 10 | 11 | "github.com/Luzifer/nginx-sso/plugins" 12 | ) 13 | 14 | type MFAYubikey struct { 15 | ClientID string `yaml:"client_id"` 16 | SecretKey string `yaml:"secret_key"` 17 | } 18 | 19 | func New() *MFAYubikey { 20 | return &MFAYubikey{} 21 | } 22 | 23 | // ProviderID needs to return an unique string to identify 24 | // this special MFA provider 25 | func (m MFAYubikey) ProviderID() (id string) { return "yubikey" } 26 | 27 | // Configure loads the configuration for the Authenticator from the 28 | // global config.yaml file which is passed as a byte-slice. 29 | // If no configuration for the Authenticator is supplied the function 30 | // needs to return the plugins.ErrProviderUnconfigured 31 | func (m *MFAYubikey) Configure(yamlSource []byte) (err error) { 32 | envelope := struct { 33 | MFA struct { 34 | Yubikey *MFAYubikey `yaml:"yubikey"` 35 | } `yaml:"mfa"` 36 | }{} 37 | 38 | if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { 39 | return err 40 | } 41 | 42 | if envelope.MFA.Yubikey == nil { 43 | return plugins.ErrProviderUnconfigured 44 | } 45 | 46 | m.ClientID = envelope.MFA.Yubikey.ClientID 47 | m.SecretKey = envelope.MFA.Yubikey.SecretKey 48 | 49 | return nil 50 | } 51 | 52 | // ValidateMFA takes the user from the login cookie and performs a 53 | // validation against the provided MFA configuration for this user 54 | func (m MFAYubikey) ValidateMFA(res http.ResponseWriter, r *http.Request, user string, mfaCfgs []plugins.MFAConfig) error { 55 | var keyInput string 56 | 57 | yubiAuth, err := yubigo.NewYubiAuth(m.ClientID, m.SecretKey) 58 | if err != nil { 59 | return errors.Wrap(err, "Unable to create Yubikey client") 60 | } 61 | 62 | for _, c := range mfaCfgs { 63 | if c.Provider != m.ProviderID() { 64 | continue 65 | } 66 | 67 | for key, values := range r.Form { 68 | if strings.HasSuffix(key, plugins.MFALoginFieldName) && strings.HasPrefix(values[0], c.AttributeString("device")) { 69 | keyInput = values[0] 70 | } 71 | } 72 | 73 | if keyInput == "" { 74 | continue 75 | } 76 | 77 | _, ok, err := yubiAuth.Verify(keyInput) 78 | if err != nil && !strings.Contains(err.Error(), "OTP has wrong length.") { 79 | return errors.Wrap(err, "OTP verification failed") 80 | } 81 | 82 | if ok { 83 | return nil 84 | } 85 | } 86 | 87 | // Not a valid authentication 88 | return plugins.ErrNoValidUserFound 89 | } 90 | -------------------------------------------------------------------------------- /plugins/register.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | type RegisterAuthenticatorFunc func(Authenticator) 4 | type RegisterMFAProviderFunc func(MFAProvider) 5 | -------------------------------------------------------------------------------- /pongo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/flosch/pongo2" 9 | ) 10 | 11 | func init() { 12 | pongo2.RegisterFilter("to_json", filterToJSON) 13 | } 14 | 15 | func filterToJSON(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { 16 | var buf = new(bytes.Buffer) 17 | 18 | err := json.NewEncoder(buf).Encode(in.Interface()) 19 | if err != nil { 20 | return nil, &pongo2.Error{ 21 | Sender: "to_json", 22 | OrigError: err, 23 | } 24 | } 25 | 26 | result := strings.TrimSpace(buf.String()) 27 | return pongo2.AsValue(result), nil 28 | } 29 | -------------------------------------------------------------------------------- /redirect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func getRedirectURL(r *http.Request, fallback string) (string, error) { 12 | var ( 13 | params url.Values 14 | redirURL string 15 | removeParam string 16 | sessURL string 17 | ) 18 | 19 | if cookieStore != nil { 20 | sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, "main"}, "-")) 21 | if s, ok := sess.Values["go"].(string); ok { 22 | sessURL = s 23 | } 24 | } 25 | 26 | switch { 27 | case r.URL.Query().Get("rd") != "": 28 | // We have a GET request with a "rd" query param (K8s ingress-nginx) 29 | redirURL = r.URL.Query().Get("rd") 30 | removeParam = "rd" 31 | params = r.URL.Query() 32 | 33 | case r.URL.Query().Get("go") != "": 34 | // We have a GET request, use "go" query param 35 | redirURL = r.URL.Query().Get("go") 36 | removeParam = "go" 37 | params = r.URL.Query() 38 | 39 | case r.FormValue("go") != "": 40 | // We have a POST request, use "go" form value 41 | redirURL = r.FormValue("go") 42 | params = url.Values{} // No need to read other form fields 43 | 44 | case sessURL != "": 45 | redirURL = sessURL 46 | params = url.Values{} 47 | 48 | default: 49 | // No URL specified, use specified fallback URL 50 | return fallback, nil 51 | } 52 | 53 | // Remove the redirect parameter as it is a parameter for nginx-sso 54 | if removeParam != "" { 55 | params.Del(removeParam) 56 | } 57 | 58 | // Parse given URL to extract attached parameters 59 | pURL, err := url.Parse(redirURL) 60 | if err != nil { 61 | return "", errors.Wrap(err, "parsing redirect URL") 62 | } 63 | 64 | // Transfer parameters from URL to params set 65 | for k, vs := range pURL.Query() { 66 | for _, v := range vs { 67 | params.Add(k, v) 68 | } 69 | } 70 | 71 | // Re-add assembled parameters to URL 72 | pURL.RawQuery = params.Encode() 73 | 74 | return pURL.String(), nil 75 | } 76 | -------------------------------------------------------------------------------- /redirect_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | ) 8 | 9 | func TestGetRedirectGet(t *testing.T) { 10 | // Constructed URL to match a nginx redirect: 11 | // `return 302 https://login.luzifer.io/login?go=$scheme://$http_host$request_uri;` 12 | testURL := "https://example.com/login?go=https://example.com/inner?foo=bar&bar=foo" 13 | expectURL := "https://example.com/inner?bar=foo&foo=bar" 14 | 15 | req, _ := http.NewRequest(http.MethodGet, testURL, nil) 16 | 17 | rURL, err := getRedirectURL(req, "") 18 | if err != nil { 19 | t.Errorf("getRedirectURL caused an error in GET: %s", err) 20 | } 21 | 22 | if expectURL != rURL { 23 | t.Errorf("Result did not match expected URL: %q != %q", rURL, expectURL) 24 | } 25 | } 26 | 27 | func TestGetRedirectFallback(t *testing.T) { 28 | testURL := "https://example.com/login" 29 | expectURL := "https://example.com/default" 30 | 31 | req, _ := http.NewRequest(http.MethodGet, testURL, nil) 32 | 33 | rURL, err := getRedirectURL(req, expectURL) 34 | if err != nil { 35 | t.Errorf("getRedirectURL caused an error in GET: %s", err) 36 | } 37 | 38 | if expectURL != rURL { 39 | t.Errorf("Result did not match expected URL: %q != %q", rURL, expectURL) 40 | } 41 | } 42 | 43 | func TestGetRedirectPost(t *testing.T) { 44 | testURL := "https://example.com/login" 45 | expectURL := "https://example.com/inner?foo=bar" 46 | 47 | body := url.Values{ 48 | "go": []string{expectURL}, 49 | "other": []string{"param"}, 50 | } 51 | req, _ := http.NewRequest(http.MethodPost, testURL, nil) 52 | req.Form = body // Force-set the form values to emulate parsed form 53 | 54 | rURL, err := getRedirectURL(req, "") 55 | if err != nil { 56 | t.Errorf("getRedirectURL caused an error in POST: %s", err) 57 | } 58 | 59 | if expectURL != rURL { 60 | t.Errorf("Result did not match expected URL: %q != %q", rURL, expectURL) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /registry.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/Luzifer/nginx-sso/plugins" 11 | ) 12 | 13 | var ( 14 | authenticatorRegistry = []plugins.Authenticator{} 15 | authenticatorRegistryMutex sync.RWMutex 16 | 17 | activeAuthenticators = []plugins.Authenticator{} 18 | ) 19 | 20 | func registerAuthenticator(a plugins.Authenticator) { 21 | authenticatorRegistryMutex.Lock() 22 | defer authenticatorRegistryMutex.Unlock() 23 | 24 | authenticatorRegistry = append(authenticatorRegistry, a) 25 | } 26 | 27 | func initializeAuthenticators(yamlSource []byte) error { 28 | authenticatorRegistryMutex.Lock() 29 | defer authenticatorRegistryMutex.Unlock() 30 | 31 | tmp := []plugins.Authenticator{} 32 | for _, a := range authenticatorRegistry { 33 | err := a.Configure(yamlSource) 34 | 35 | switch err { 36 | case nil: 37 | tmp = append(tmp, a) 38 | log.WithFields(log.Fields{"authenticator": a.AuthenticatorID()}).Debug("Activated authenticator") 39 | case plugins.ErrProviderUnconfigured: 40 | log.WithFields(log.Fields{"authenticator": a.AuthenticatorID()}).Debug("Authenticator unconfigured") 41 | // This is okay. 42 | default: 43 | return fmt.Errorf("Authenticator configuration caused an error: %s", err) 44 | } 45 | } 46 | 47 | if len(tmp) == 0 { 48 | return fmt.Errorf("No authenticator configurations supplied") 49 | } 50 | 51 | activeAuthenticators = tmp 52 | 53 | return nil 54 | } 55 | 56 | func detectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { 57 | authenticatorRegistryMutex.RLock() 58 | defer authenticatorRegistryMutex.RUnlock() 59 | 60 | for _, a := range activeAuthenticators { 61 | user, groups, err := a.DetectUser(res, r) 62 | switch err { 63 | case nil: 64 | return user, groups, err 65 | case plugins.ErrNoValidUserFound: 66 | // This is okay. 67 | default: 68 | return "", nil, err 69 | } 70 | } 71 | 72 | return "", nil, plugins.ErrNoValidUserFound 73 | } 74 | 75 | func loginUser(res http.ResponseWriter, r *http.Request) (string, []plugins.MFAConfig, error) { 76 | authenticatorRegistryMutex.RLock() 77 | defer authenticatorRegistryMutex.RUnlock() 78 | 79 | for _, a := range activeAuthenticators { 80 | user, mfaCfgs, err := a.Login(res, r) 81 | switch err { 82 | case nil: 83 | return user, mfaCfgs, nil 84 | case plugins.ErrNoValidUserFound: 85 | // This is okay. 86 | default: 87 | return "", nil, err 88 | } 89 | } 90 | 91 | return "", nil, plugins.ErrNoValidUserFound 92 | } 93 | 94 | func logoutUser(res http.ResponseWriter, r *http.Request) error { 95 | authenticatorRegistryMutex.RLock() 96 | defer authenticatorRegistryMutex.RUnlock() 97 | 98 | for _, a := range activeAuthenticators { 99 | if err := a.Logout(res, r); err != nil { 100 | return err 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func getFrontendAuthenticators() map[string][]plugins.LoginField { 108 | authenticatorRegistryMutex.RLock() 109 | defer authenticatorRegistryMutex.RUnlock() 110 | 111 | output := map[string][]plugins.LoginField{} 112 | for _, a := range activeAuthenticators { 113 | if len(a.LoginFields()) == 0 { 114 | continue 115 | } 116 | output[a.AuthenticatorID()] = a.LoginFields() 117 | 118 | if a.SupportsMFA() && !mainCfg.Login.HideMFAField { 119 | output[a.AuthenticatorID()] = append(output[a.AuthenticatorID()], mfaLoginField) 120 | } 121 | } 122 | 123 | return output 124 | } 125 | --------------------------------------------------------------------------------