diff --git a/.defaults.yml b/.defaults.yml new file mode 100644 index 00000000..9b5f8e5e --- /dev/null +++ b/.defaults.yml @@ -0,0 +1,72 @@ +# default values for Vouch Proxy +# this is related to Env Vars +# https://github.com/vouch/vouch-proxy/issues/132 +# https://github.com/vouch/vouch-proxy/pull/134 + +# you don't want to mess with these +vouch: + logLevel: info + testing: false + listen: 0.0.0.0 + port: 9090 + socket_mode: 0660 + # document_root: + # domains: + allowAllUsers: false + publicAccess: false + # whiteList: + # teamWhitelist: + writeTimeout: 15 + readTimeout: 15 + idleTimeout: 15 + tls: + # cert: + # key: + profile: intermediate + + jwt: + # secret: + issuer: Vouch + maxAge: 240 + compress: true + signing_method: HS256 + + cookie: + name: VouchCookie + # domain: + secure: true + httpOnly: true + maxAge: 240 + # sameSite: + + session: + name: VouchSession + maxAge: 5 + # key: + + headers: + jwt: X-Vouch-Token + user: X-Vouch-User + success: X-Vouch-Success + error: X-Vouch-Error + querystring: access_token + redirect: X-Vouch-Requested-URI + # claims: + claimheader: X-Vouch-IdP-Claims- + # https://github.com/vouch/vouch-proxy/issues/287 + # accesstoken: X-Vouch-IdP-AccessToken + # idtoken: X-Vouch-IdP-IdToken + # test_url: + # post_logout_redirect_uris: +# oauth: +# provider: +# client_id: +# client_secret: +# callback_url: +# callback_urls: +# preferredDomain: +# auth_url: +# token_url: +# user_info_url: +# end_session_endpoint: +# scopes: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..7cd8d249 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +config/config.yml +pkg/model/storage-test.db +main +config/google_config.json +.vscode/* +lasso +config/config.yml_google +config/config.yml_github +config/secret +config/config.yml_orig +.dockerignore +Dockerfile +handlers/rice-box.go +certs +.cover/* +.github +.whitesource diff --git a/.github/ISSUE_TEMPLATE/open-a-github-issue-to-receive-support.md b/.github/ISSUE_TEMPLATE/open-a-github-issue-to-receive-support.md new file mode 100644 index 00000000..022e2e01 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/open-a-github-issue-to-receive-support.md @@ -0,0 +1,39 @@ +--- +name: open a GitHub Issue to receive support +about: Create a report to receive support with your config and to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**First read the README** +Specifically ***[Troubleshooting, Support and Feature Requests](https://github.com/vouch/vouch-proxy#troubleshooting-support-and-feature-requests-read-this-before-submitting-an-issue-at-github)***. + +And please turn on `vouch.testing` before you ask for support. + + + +** Please DO NOT post config and logs to this issue, use a Gist** +We prefer a [gist](https://gist.github.com/) but any reasonable paste service is fine. + +**Describe the problem** +A clear and concise description of the behavior you are observing. +Please include which OAuth provider you are using. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..9b185a15 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,50 @@ +name: coverage + +on: + workflow_dispatch: + push: + pull_request: + +jobs: + coverage: + env: + GOPATH: ${{ github.workspace }} + VOUCH_ROOT: ${{ github.workspace }}/src/github.com/${{ github.repository }} + defaults: + run: + working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # go: ['1.14', '1.15'] + go: ['1.26'] + + steps: + - uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go }} + - name: checkout + uses: actions/checkout@v2 + with: + path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} + - name: goget + run: ./do.sh goget + - name: coverage test + run: ./do.sh coverage + + - name: Send coverage + uses: shogo82148/actions-goveralls@v1 + with: + path-to-profile: ${{ env.GOPATH }}/src/github.com/${{ github.repository }}/.cover/cover.out + flag-name: Go-${{ matrix.go }} + parallel: true + + # notifies that all test jobs are finished. + finish: + needs: coverage + runs-on: ubuntu-latest + steps: + - uses: shogo82148/actions-goveralls@v1 + with: + parallel-finished: true \ No newline at end of file diff --git a/.github/workflows/docker-release-arm.yml b/.github/workflows/docker-release-arm.yml new file mode 100644 index 00000000..a28f9f7f --- /dev/null +++ b/.github/workflows/docker-release-arm.yml @@ -0,0 +1,43 @@ +name: Docker build and push voucher/vouch-proxy:latest-arm + +on: + push: + branches: + - master + +jobs: + publish-to-docker-arm: + runs-on: ubuntu-latest + env: + DOCKER_TAG: latest-arm + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Docker Buildx + id: buildx + uses: crazy-max/ghaction-docker-buildx@v1 + with: + version: latest + - name: List available platforms + run: echo ${{ steps.buildx.outputs.platforms }} + - name: Docker login (set DOCKER_USERNAME and DOCKER_PASSWORD in secrets) + if: ${{ success() && startsWith(github.repository, 'vouch/')}} # Remove this line, if you want everybody to publish to docker hub + run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + - name: Publish to docker as voucher/vouch-proxy + if: ${{ success() && startsWith(github.repository, 'vouch/')}} + run: | + docker buildx build \ + --platform linux/arm/v7,linux/arm64 \ + --push \ + -t voucher/vouch-proxy:$DOCKER_TAG \ + . + # Uncomment below to have github build to docker for every user. Watch out for indentation + # - + # name: Publish to docker as github_user/github_repo + # if: ${{ success() && !startsWith(github.repository, 'vouch/')}} + # run: | + # docker buildx build \ + # --platform linux/amd64,linux/arm/v7,linux/arm64 \ + # --push \ + # -t $GITHUB_REPOSITORY:latest \ + # . diff --git a/.github/workflows/docker-release-quayio-alpine.yml b/.github/workflows/docker-release-quayio-alpine.yml new file mode 100644 index 00000000..ca76c3cc --- /dev/null +++ b/.github/workflows/docker-release-quayio-alpine.yml @@ -0,0 +1,56 @@ +name: Publish Docker image to Quay.io using Dockerfile.alpine + +on: + push: + branches: + - master + tags: + - 'v*' + + +jobs: + publish-to-docker-quayio: + runs-on: ubuntu-latest + env: + DOCKER_REPO: quay.io + + steps: + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker repository + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.DOCKER_REPO }} + username: ${{ secrets.QUAYIO_ROBOT_USERNAME }} + password: ${{ secrets.QUAYIO_ROBOT_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@a67f45cb0f8e65cf693a0bc5bfa1c5057c623030 + with: + images: quay.io/vouch/vouch-proxy + flavor: | + prefix=alpine-,onlatest=true + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push Docker image using Dockerfile.alpine + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + file: Dockerfile.alpine + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: | + linux/amd64 + linux/arm64 \ No newline at end of file diff --git a/.github/workflows/docker-release-quayio.yml b/.github/workflows/docker-release-quayio.yml new file mode 100644 index 00000000..aecd768b --- /dev/null +++ b/.github/workflows/docker-release-quayio.yml @@ -0,0 +1,53 @@ +name: Publish Docker image to Quay.io + +on: + push: + branches: + - master + tags: + - 'v*' + + +jobs: + publish-to-docker-quayio: + runs-on: ubuntu-latest + env: + DOCKER_REPO: quay.io + + steps: + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker repository + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.DOCKER_REPO }} + username: ${{ secrets.QUAYIO_ROBOT_USERNAME }} + password: ${{ secrets.QUAYIO_ROBOT_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: quay.io/vouch/vouch-proxy + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push Docker image using Dockerfile + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: | + linux/amd64 + linux/arm64 diff --git a/.github/workflows/notify-irc.yml b/.github/workflows/notify-irc.yml new file mode 100644 index 00000000..c98def9c --- /dev/null +++ b/.github/workflows/notify-irc.yml @@ -0,0 +1,35 @@ +name: "Push Notification" +on: [push, pull_request, create] + +jobs: + notify-irc: + runs-on: ubuntu-latest + steps: + - name: irc push + uses: rectalogic/notify-irc@v1 + if: github.event_name == 'push' + with: + channel: "#vouch" + server: irc.libera.chat + nickname: from-github + message: | + ${{ github.actor }} pushed ${{ github.event.ref }} ${{ github.event.compare }} + ${{ join(github.event.commits.*.message) }} + - name: irc pull request + uses: rectalogic/notify-irc@v1 + if: github.event_name == 'pull_request' + with: + channel: "#vouch" + server: irc.libera.chat + nickname: from-github + message: | + ${{ github.actor }} opened PR ${{ github.event.html_url }} + - name: irc tag created + uses: rectalogic/notify-irc@v1 + if: github.event_name == 'create' && github.event.ref_type == 'tag' + with: + channel: "#vouch" + server: irc.libera.chat + nickname: from-github + message: | + ${{ github.actor }} tagged ${{ github.repository }} ${{ github.event.ref }} diff --git a/.gitignore b/.gitignore index 5941f189..7d708432 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,19 @@ -config/config.yml -data/lasso_bolt.db -pkg/model/storage-test.db +vouch +vouch-proxy main +config/config.yml +config/*config.yml +config/config.yml_* +!config/config.yml_example_pocket-id config/google_config.json -.vscode/* \ No newline at end of file +config/secret +!config/testing/* +pkg/model/storage-test.db +.vscode/* +certs/* +coverage.out +coverage.html.env_google +.env* +.cover +config/testing/rsa.key +config/testing/rsa.pub diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..51378eca --- /dev/null +++ b/.travis.yml @@ -0,0 +1,43 @@ +language: go +go_import_path: github.com/vouch/vouch-proxy + +sudo: false + +services: + - docker + +go: + - "1.26" + +env: + - ISTRAVIS=true + +before_install: + - sudo apt-get install openssl + - ./do.sh goget + # - go get github.com/golang/lint/golint # Linter + # - go get github.com/fzipp/gocyclo + +script: + # TODO: enable gofmt + # - gofmt -w -s . && git diff --exit-code + - ./do.sh build + - ./do.sh test +# - docker build -t $TRAVIS_REPO_SLUG . + +#deploy: +# - provider: script +# skip_cleanup: true +# script: bash .travis/docker_push +# on: +# go: "1.10" +# branch: master +# - provider: script +# skip_cleanup: true +# script: bash .travis/docker_push +# on: +# go: "1.10" +# tags: true +# +notifications: + irc: "irc.libera.chat#vouch" diff --git a/.travis/docker_push b/.travis/docker_push new file mode 100644 index 00000000..662761a0 --- /dev/null +++ b/.travis/docker_push @@ -0,0 +1,7 @@ +#!/bin/bash +echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin +docker push $TRAVIS_REPO_SLUG +if [ "$TRAVIS_BRANCH" != "master" ]; then + docker tag $TRAVIS_REPO_SLUG $TRAVIS_REPO_SLUG:$TRAVIS_BRANCH + docker push $TRAVIS_REPO_SLUG:$TRAVIS_BRANCH +fi diff --git a/.whitesource b/.whitesource new file mode 100644 index 00000000..55b922e8 --- /dev/null +++ b/.whitesource @@ -0,0 +1,12 @@ +{ + "scanSettings": { + "baseBranches": [] + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff" + }, + "issueSettings": { + "minSeverityLevel": "LOW" + } +} \ No newline at end of file diff --git a/AUTHORS.txt b/AUTHORS.txt new file mode 100644 index 00000000..8f5d90ad --- /dev/null +++ b/AUTHORS.txt @@ -0,0 +1,2 @@ +bnfinet +aaronpk \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..82f4f36c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog for Vouch Proxy + +## Unreleased + +Coming soon! Please document any work in progress here as part of your PR. It will be moved to the next tag when released. + +## v0.48.0 SECURITY UPDATE + +- fixes [Unbounded Multipart Cookie Allocation DoS in vouch-proxy](https://github.com/vouch/vouch-proxy/security/advisories/GHSA-qqff-5854-px68) + - improve checks of the number of parts of multipart cookies + - set limit of number of cookie parts + +## v0.47.2 + +- fix [#608](https://github.com/vouch/vouch-proxy/issues/608) accomodating golang-jwt/jwt/v5 audience string format - HT @macourteau + +## v0.47.1 + +- set Cookie.Expires to Jan 1 1970 to delete the VP cookie in a manner most browsers will comply with + +## v0.47.0 + +- improve host check against jwt audience claim +- upgrade to github.com/golang-jwt/jwt/v5 + +## v0.46.0 + +- upgrade golang to `v1.26` from `v1.23` + +## v0.45.0 + +- Implement a Discord provider that uses `Username` as the username to match against in the `whiteList` config + - Or uses `Username#Discriminator` if the Discriminator is present + - Or uses ID if `discord_use_ids` is set + +## v0.44.0 + +- migrate to github.com/golang-jwt/jwt/v4 + +## v0.43.0 + +- support multi-platform / multi-arch builds for published Docker images including `linux/amd64` and `linux/arm64` + +## v0.42.0 + +- [fix auth to github](https://github.com/vouch/vouch-proxy/pull/601) +- cleanup of minor issues flagged by gostaticcheck + +## v0.41.0 + +- upgrade golang to `v1.23` from `v1.22` + +## v0.40.0 + +- upgrade golang to `v1.22` from `v1.18` + +## v0.39.0 + +- [add support for listening on unix domain sockets](https://github.com/vouch/vouch-proxy/pull/488) + +## v0.38.0 + +- upgrade golang to `v1.18` from `v1.16` + +## v0.37.0 + +- [allow configurable Write, Read and Idle timeouts for the http server](https://github.com/vouch/vouch-proxy/pull/468) + +## v0.36.0 + +- [run Docker containers as non-root user](https://github.com/vouch/vouch-proxy/pull/444) + +Permissions may need to be adjusted for `/config/secret` and `/config/config.yml` in Docker environemnts. See the [README](https://github.com/vouch/vouch-proxy#running-from-docker) + +## v0.35.1 + +- [include DocumentRoot if configured in error pages](https://github.com/vouch/vouch-proxy/pull/439) + +## v0.35.0 + +- [make session.MaxAge configurable](https://github.com/vouch/vouch-proxy/issues/318) to allow more time to login at the IdP + +## v0.34.2 + +- [log github token only at `logLevel: debug`](https://github.com/vouch/vouch-proxy/pull/436) +- documentation edits +- move `cookie.sameSite` configuration to `cookie.Configure()` + +## v0.34.1 + +- bug fix: [Azure provider no longer requires `oauth.user_info_url` to be configured](https://github.com/vouch/vouch-proxy/issues/417) + +## v0.34.0 + +- add support for [the "claims" Request Parameter](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter) to support Twitch OIDC as IdP +- add [Twitch OIDC example](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_twitch) + +## v0.33.0 + +- [Vouch Proxy running in a path](https://github.com/vouch/vouch-proxy/issues/373) + +## v0.32.0 + +- [Slack oidc example](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_slack) and [slack app manifest](https://github.com/vouch/vouch-proxy/blob/master/examples/slack/vouch-slack-oidc-app-manifest.yml) +- [CHANGELOG.md](https://github.com/vouch/vouch-proxy/blob/master/CHANGELOG.md) + +## v0.31.0 + +- [use quay.io](https://quay.io/repository/vouch/vouch-proxy?tab=tags) instead of Docker Hub for docker image hosting +- use [httprouter's](https://github.com/julienschmidt/httprouter) more performant mux + +## v0.29.0 + +- embed static assets as templates using [go:embed](https://golang.org/pkg/embed/) + +## v0.28.0 + +- add support for a custom 'relying party identifier' for ADFS + +_the rest is history_ and can be teased out with `git log` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..8d7f05a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Setting up a Development Environment + +## Running Tests + +```bash + export VOUCH_ROOT=`pwd` # if not using GOPATH + ./do.sh test +``` + +After running these tests manually once (which creates a test key pair), it is possible to run the tests with VSCode Test Explorer. + +To get it to work, we must set some environment variables in `.vscode/settings.json` which otherwise would be set by `do.sh`: + +```json +{ + "go.testEnvVars": { + "VOUCH_ROOT": "${workspaceFolder}", + "VOUCH_CONFIG": "${workspaceFolder}/config/testing/test_config.yml", + "TEST_PRIVATE_KEY_FILE": "${workspaceFolder}/config/testing/rsa.key", + "TEST_PUBLIC_KEY_FILE": "${workspaceFolder}/config/testing/rsa.pub", + } +} +``` + +### Contributing to Vouch Proxy by submitting a Pull Request + +**_I really love Vouch Proxy! I wish it did XXXX..._** + +That's really wonderful and contributions are greatly appreciated. However, please search through the existing issues, both open and closed, to look for any prior work or conversation. Then please make a proposal before we all spend valuable time considering and integrating a new feature. + +Code contributions should.. + +- generally be discussed beforehand in a GitHub issue +- include unit tests and in some cases end-to-end tests +- be formatted with `go fmt`, checked with `go vet` and other common go tools +- accomodate configuration via `config.yml` as well as `ENVIRONMENT_VARIABLEs`. +- not break existing setups without a clear reason (usually security related) +- include an entry at the top of CHANGELOG.md in the **Unreleased** section + +For larger contributions or code related to a platform that we don't currently support we will ask you to commit to supporting the feature for an agreed upon period. Invariably someone will pop up here with a question and we want to be able to support these requests. + +**Thank you to all of the contributors that have provided their time and effort and thought to improving VP.** diff --git a/Dockerfile b/Dockerfile index e2f797e9..f70533d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,33 @@ -# bfoote/lasso -# https://github.com/bnfinet/lasso -FROM golang:1.8 +# quay.io/vouch/vouch-proxy +# https://github.com/vouch/vouch-proxy +FROM golang:1.26 AS builder + +ARG UID=999 +ARG GID=999 +LABEL maintainer="vouch@bnf.net" + +RUN mkdir -p ${GOPATH}/src/github.com/vouch/vouch-proxy +WORKDIR ${GOPATH}/src/github.com/vouch/vouch-proxy + +RUN groupadd -g $GID vouch \ + && useradd --system vouch --uid=$UID --gid=$GID -RUN mkdir -p ${GOPATH}/src/github.com/bnfinet/lasso -WORKDIR ${GOPATH}/src/github.com/bnfinet/lasso - COPY . . -RUN go-wrapper download # "go get -d -v ./..." -RUN go-wrapper install # "go install -v ./..." -RUN rm -rf ./config ./data \ - && ln -s /config ./config \ - && ln -s /data ./data +RUN ./do.sh goget +RUN ./do.sh gobuildstatic # see `do.sh` for vouch-proxy build details +RUN ./do.sh install + +FROM scratch +LABEL maintainer="vouch@bnf.net" +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder /go/bin/vouch-proxy /vouch-proxy + +USER vouch EXPOSE 9090 -CMD ["/go/bin/lasso"] +ENTRYPOINT ["/vouch-proxy"] +HEALTHCHECK --interval=1m --timeout=5s CMD [ "/vouch-proxy", "-healthcheck" ] diff --git a/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 00000000..7b879a11 --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,38 @@ +# quay.io/vouch/vouch-proxy +# https://github.com/vouch/vouch-proxy +FROM golang:1.26 AS builder + +ARG UID=999 +ARG GID=999 +LABEL maintainer="vouch@bnf.net" + +RUN mkdir -p ${GOPATH}/src/github.com/vouch/vouch-proxy +WORKDIR ${GOPATH}/src/github.com/vouch/vouch-proxy + +COPY . . + +RUN ./do.sh goget +RUN ./do.sh gobuildstatic # see `do.sh` for vouch-proxy build details +RUN ./do.sh install + +RUN groupadd -g $GID vouch \ + && useradd --system vouch --uid=$UID --gid=$GID + +FROM alpine:latest +LABEL maintainer="vouch@bnf.net" +ENV VOUCH_ROOT=/ +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +# do.sh requires bash +RUN apk add --no-cache bash +COPY do.sh /do.sh + +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder /go/bin/vouch-proxy /vouch-proxy + +USER vouch + +EXPOSE 9090 +ENTRYPOINT ["/vouch-proxy"] +HEALTHCHECK --interval=1m --timeout=5s CMD [ "/vouch-proxy", "-healthcheck" ] diff --git a/LICENSE b/LICENSE index 313e9ff7..cbd1b0fd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2017 Benjamin Foote +Copyright (c) 2017 The Vouch Proxy Authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 0235ee2c..67642988 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,539 @@ -# Lasso +# Vouch Proxy -an SSO solution for an nginx reverse proxy using the [auth_request](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module +[![GitHub stars](https://img.shields.io/github/stars/vouch/vouch-proxy.svg)](https://github.com/vouch/vouch-proxy) +[![Build Status](https://travis-ci.org/vouch/vouch-proxy.svg?branch=master)](https://travis-ci.org/vouch/vouch-proxy) +[![Go Report Card](https://goreportcard.com/badge/github.com/vouch/vouch-proxy)](https://goreportcard.com/report/github.com/vouch/vouch-proxy) +[![MIT license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/vouch/vouch-proxy/blob/master/LICENSE) +[![GitHub version](https://img.shields.io/github/v/tag/vouch/vouch-proxy.svg?sort=semver&color=green)](https://github.com/vouch/vouch-proxy) -lasso supports oauth for google apps, [github](https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/) and [indieauth](https://indieauth.com/developers) +An SSO solution for Nginx using the [auth_request](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module. Vouch Proxy can protect all of your websites at once. -If lasso is running on the same host as the nginx reverse proxy the response time from the `/validate` endpoint to nginx should be less than 1ms +Vouch Proxy supports many OAuth and OIDC login providers and can enforce authentication to... -For support please file tickets here or visit our IRC channel [#lasso](irc://freenode.net/#lasso) on freenode +- [Google](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_google) +- [GitHub](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_github) +- [GitHub Enterprise](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_github_enterprise) +- [IndieAuth](https://indieauth.spec.indieweb.org/) +- [Okta](https://developer.okta.com/blog/2018/08/28/nginx-auth-request) +- [Slack](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_slack) +- [ADFS](https://github.com/vouch/vouch-proxy/pull/68) +- [Azure AD](https://github.com/vouch/vouch-proxy/issues/290) +- [Alibaba / Aliyun iDaas](https://github.com/vouch/vouch-proxy/issues/344) +- [AWS Cognito](https://github.com/vouch/vouch-proxy/issues/105) +- [Twitch](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_twitch) +- [Discord](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_discord) +- [SecureAuth](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_secureauth) +- [Gitea](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_gitea) +- [Keycloak](config/config.yml_example_keycloak) +- [OAuth2 Server Library for PHP](https://github.com/vouch/vouch-proxy/issues/99) +- [HomeAssistant](https://developers.home-assistant.io/docs/en/auth_api.html) +- [OpenStax](https://github.com/vouch/vouch-proxy/pull/141) +- [Ory Hydra](https://github.com/vouch/vouch-proxy/issues/288) +- [Nextcloud](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/oauth2.html) +- [Pocket ID](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_pocket-id) +- most other OpenID Connect (OIDC) providers -## Installation +Please do let us know when you have deployed Vouch Proxy with your preffered IdP or library so we can update the list. -* `cp ./config/config.yml_example ./config/config.yml` -* create oauth credentials for lasso at [google](https://console.developers.google.com/apis/credentials) or [github](https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/) - * be sure to direct the callback URL to the `/auth` endpoint -* configure nginx... +If Vouch is running on the same host as the Nginx reverse proxy the response time from the `/validate` endpoint to Nginx should be **less than 1ms**. -```{.nginxconf} +--- + +## Table of Contents + +- [What Vouch Proxy Does...](#what-vouch-proxy-does) +- [Installation and Configuration](#installation-and-configuration) + - [Vouch Proxy "in a path"](#vouch-proxy-in-a-path) + - [Additional Nginx Configurations](#additional-nginx-configurations) + - [Configuration via Environmental Variables](#configuring-via-environmental-variables) +- [Tips, Tricks and Advanced Configurations](#tips-tricks-and-advanced-configurations) + - [Scopes and Claims](#scopes-and-claims) +- [Running from Docker](#running-from-docker) +- [Kubernetes Nginx Ingress](#kubernetes-nginx-ingress) +- [Compiling from source and running the binary](#compiling-from-source-and-running-the-binary) +- [/login and /logout endpoint redirection](#login-and-logout-endpoint-redirection) +- [Troubleshooting, Support and Feature Requests](#troubleshooting-support-and-feature-requests-read-this-before-submitting-an-issue-at-github) + (Read this before submitting an issue at GitHub) + - [I'm getting an infinite redirect loop which returns me to my IdP (Google/Okta/GitHub/...)](#im-getting-an-infinite-redirect-loop-which-returns-me-to-my-idp-googleoktagithub) + - [Okay, I looked at the issues and have tried some things with my configs but it's still not working](#okay-i-looked-at-the-issues-and-have-tried-some-things-with-my-configs-but-its-still-not-working) + - [Contributing to Vouch Proxy](#contributing) +- [Advanced Authorization Using OpenResty](#advanced-authorization-using-openresty) +- [The flow of login and authentication using Google Oauth](#the-flow-of-login-and-authentication-using-google-oauth) + +## What Vouch Proxy Does + +Vouch Proxy (VP) forces visitors to login and authenticate with an [IdP](https://en.wikipedia.org/wiki/Identity_provider) (such as one of the services listed above) before allowing them access to a website. + +![Vouch Proxy protects websites](https://github.com/vouch/vouch-proxy/blob/master/examples/nginx-vouch-private_simple.png?raw=true) + +VP can also be used as a Single Sign On (SSO) solution to protect all web applications in the same domain. + +![Vouch Proxy is a Single Sign On solution](https://github.com/vouch/vouch-proxy/blob/master/examples/nginx-vouch-private_appA_appB_appC.png?raw=true) + +After a visitor logs in Vouch Proxy allows access to the protected websites for several hours. Every request is checked by VP to ensure that it is valid. + +VP can send the visitor's email, name and other information which the IdP provides (including access tokens) to the web application as HTTP headers. VP can be used to replace application user management entirely. + +## Installation and Configuration + +Vouch Proxy relies on the ability to share a cookie between the Vouch Proxy server and the application it's protecting. Typically this will be done by running Vouch on a subdomain such as `vouch.yourdomain.com` with apps running at `app1.yourdomain.com` and `app2.yourdomain.com`. The protected domain is `.yourdomain.com` and the Vouch Proxy cookie must be set in this domain by setting [vouch.domains](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example#L38-L48) to include `yourdomain.com` or sometimes by setting [vouch.cookie.domain](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example#L109-L114) to `yourdomain.com`. + +- `cp ./config/config.yml_example_$OAUTH_PROVIDER ./config/config.yml` +- create OAuth credentials for Vouch Proxy at [google](https://console.developers.google.com/apis/credentials) or [github](https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/), etc + - be sure to direct the callback URL to the Vouch Proxy `/auth` endpoint +- configure Nginx... + +The following Nginx config assumes.. + +- Nginx, `vouch.yourdomain.com` and `protectedapp.yourdomain.com` are running on the same server +- both domains are served as `https` and have valid certs (if not, change to `listen 80` and set [vouch.cookie.secure](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example#L84-L85) to `false`) + +```nginx server { - listen 80 default_server; - server_name dev.yourdomain.com; + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; root /var/www/html/; + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + # send all requests to the `/validate` endpoint for authorization auth_request /validate; - # if validate returns `401 not authorized` then forward the request to the error401block - error_page 401 = @error401; location = /validate { - # lasso can run behind the same nginx-revproxy - # May need to add "internal", and comply to "upstream" server naming - proxy_pass http://lasso.yourdomain.com:9090; + # forward the /validate request to Vouch Proxy + proxy_pass http://127.0.0.1:9090/validate; + # be sure to pass the original host header + proxy_set_header Host $http_host; - # lasso only acts on the request headers + # Vouch Proxy only acts on the request headers proxy_pass_request_body off; proxy_set_header Content-Length ""; - # not currently - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # optionally add X-Vouch-IdP-Claims-* custom claims you are tracking + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + # optinally add X-Vouch-IdP-AccessToken or X-Vouch-IdP-IdToken + # auth_request_set $auth_resp_x_vouch_idp_accesstoken $upstream_http_x_vouch_idp_accesstoken; + # auth_request_set $auth_resp_x_vouch_idp_idtoken $upstream_http_x_vouch_idp_idtoken; # these return values are used by the @error401 call - auth_request_set $auth_resp_jwt $upstream_http_x_lasso_jwt; - auth_request_set $auth_resp_err $upstream_http_x_lasso_err; - auth_request_set $auth_resp_failcount $upstream_http_x_lasso_failcount; + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + + # Vouch Proxy can run behind the same Nginx reverse proxy + # may need to comply to "upstream" server naming + # proxy_pass http://vouch.yourdomain.com/validate; + # proxy_set_header Host $http_host; } + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + location @error401 { - # redirect to lasso for login - return 302 https://lasso.yourdomain.com:9090/login?url=$scheme://$http_host$request_uri&lasso-failcount=$auth_resp_failcount&X-Lasso-Token=$auth_resp_jwt&error=$auth_resp_err; + # redirect to Vouch Proxy for login + return 302 https://vouch.yourdomain.com/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + # you usually *want* to redirect to Vouch running behind the same Nginx config proteced by https + # but to get started you can just forward the end user to the port that vouch is running on + # return 302 http://vouch.yourdomain.com:9090/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + location / { + # forward authorized requests to your service protectedapp.yourdomain.com + proxy_pass http://127.0.0.1:8080; + # you may need to set these variables in this block as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + # proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_groups; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; } } ``` -If lasso is configured behind the **same** nginx reverseproxy (perhaps so you can configure ssl) be sure to pass the `Host` header properly, otherwise the JWT cookie cannot be set into the domain +If Vouch is configured behind the **same** nginx reverseproxy ([perhaps so you can configure ssl](https://github.com/vouch/vouch-proxy/issues/64#issuecomment-461085139)) be sure to pass the `Host` header properly, otherwise the JWT cookie cannot be set into the domain + +```nginx +server { + listen 443 ssl http2; + server_name vouch.yourdomain.com; + ssl_certificate /etc/letsencrypt/live/vouch.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vouch.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:9090; + # be sure to pass the original host header + proxy_set_header Host $http_host; + } +} +``` + +### Vouch Proxy "in a path" + +As of `v0.33.0` Vouch Proxy can be served within an Nginx location (path) by configuring `vouch.document_root: /vp_in_a_path` + +This avoids the need to setup a separate domain for Vouch Proxy such as `vouch.yourdomain.com`. For example VP login will be served from `https://protectedapp.yourdomain.com/vp_in_a_path/login` -```{.nginxconf} +```nginx server { - listen 80 default_server; - server_name lasso.yourdomain.com; + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + + # This location serves all Vouch Proxy endpoints as /vp_in_a_path/$uri + # including /vp_in_a_path/validate, /vp_in_a_path/login, /vp_in_a_path/logout, /vp_in_a_path/auth, /vp_in_a_path/auth/$STATE, etc + location /vp_in_a_path { + proxy_pass http://127.0.0.1:9090; # must not! have a slash at the end + proxy_set_header Host $http_host; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if /vp_in_a_path/validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://protectedapp.yourdomain.com/vp_in_a_path/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + location / { - proxy_set_header Host lasso.yourdomain.com; - proxy_pass http://127.0.0.1:9090; + auth_request /vp_in_a_path/validate; + proxy_pass http://127.0.0.1:8080; + # see the Nginx config above for additional headers which can be set from Vouch Proxy } } +``` + +### Additional Nginx Configurations + +Additional Nginx configurations can be found in the [examples](https://github.com/vouch/vouch-proxy/tree/master/examples) directory. + +### Configuring via Environmental Variables + +Here's a minimal setup using Google's OAuth... +```bash +VOUCH_DOMAINS=yourdomain.com \ + OAUTH_PROVIDER=google \ + OAUTH_CLIENT_ID=1234 \ + OAUTH_CLIENT_SECRET=secretsecret \ + OAUTH_CALLBACK_URL=https://vouch.yourdomain.com/auth \ + ./vouch-proxy ``` +Environmental variable names are documented in [config/config.yml_example](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example) + +All lists with multiple values must be comma separated: `VOUCH_DOMAINS="yourdomain.com,yourotherdomain.com"` + +The variable `VOUCH_CONFIG` can be used to set an alternate location for the configuration file. `VOUCH_ROOT` can be used to set an alternate root directory for Vouch Proxy to look for support files. + +## Tips, Tricks and Advanced Configurations + +All Vouch Proxy configuration items are documented in [config/config.yml_example](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example) + +- [Cacheing of the Vouch Proxy `/validate` response in Nginx](https://github.com/vouch/vouch-proxy/issues/76#issuecomment-464028743) +- [Handling `OPTIONS` requests when protecting an API with Vouch Proxy](https://github.com/vouch/vouch-proxy/issues/216) +- [Validation by GitHub Team or GitHub Org](https://github.com/vouch/vouch-proxy/pull/205) +- [Running VP on a Raspberry Pi using the ARM based Docker image](https://github.com/vouch/vouch-proxy/pull/247) +- [Kubernetes architecture post ingress](https://github.com/vouch/vouch-proxy/pull/263#issuecomment-628297832) +- [set `HTTP_PROXY` to relay Vouch Proxy IdP requests through an outbound proxy server](https://github.com/vouch/vouch-proxy/issues/291) +- [Reverse Proxy for Google Cloud Run Services](https://github.com/karthikv2k/oauth_reverse_proxy) +- [Enable native TLS in Vouch Proxy](https://github.com/vouch/vouch-proxy/pull/332#issue-522612010) +- [FreeBSD support](https://github.com/vouch/vouch-proxy/issues/368) +- [`systemd` startup of Vouch Proxy](https://github.com/vouch/vouch-proxy/tree/master/examples/startup) +- [Using Node.js instead of Nginx to route requests](https://github.com/vouch/vouch-proxy/issues/359) +- [Developing a Single Page App (SPA) while consuming a VP protected API](https://github.com/vouch/vouch-proxy/issues/416) +- [Integrate Vouch Proxy into a server side application for User Authn and Authz](https://github.com/vouch/vouch-proxy/issues/421) +- [Filter by IP address before VP validation by using `satisfy any;`](https://github.com/vouch/vouch-proxy/issues/378#issuecomment-814423460) + +Please do help us to expand this list. + +### Scopes and Claims + +With Vouch Proxy you can request various `scopes` (standard and custom) to obtain more information about the user or gain access to the provider's APIs. Internally, Vouch Proxy launches a requests to `user_info_url` after successful authentication. The required `claims` are extracted from the provider's response and stored in the VP cookie. + +⚠️ **Additional claims and tokens will be added to the VP cookie and can make it large** + +The VP cookie may be split into several cookies to accomdate browser cookie size limits. But if you need it, you need it. Large cookies and headers require Nginx to be configured with larger buffers. See [large_client_header_buffers](http://nginx.org/en/docs/http/ngx_http_core_module.html#large_client_header_buffers) and [proxy_buffer_size](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size) for more information. + +#### Setup `scopes` and `claims` in Vouch Proxy with Nginx + +0. Configure Vouch Proxy for Nginx and your IdP as normal (See: [Installation and Configuration](#installation-and-configuration)) + +1. Set the necessary `scope`s in the `oauth` section of the vouch-proxy `config.yml` ([example config](config/config.yml_example_scopes_and_claims)) + 1. set `idtoken: X-Vouch-IdP-IdToken` in the `headers` section of vouch-proxy's `config.yml` + 2. log in and call the `/validate` endpoint in a modern browser + 3. check the response header for a `X-Vouch-IdP-IdToken` header + 4. copy the value of the header into the debugger at [https://jwt.io/](https://jwt.io/) and ensure that the necessary claims are part of the jwt + 5. if they are not, you need to adjust the `scopes` in the `oauth` section of your `config.yml` or reconfigure your oauth provider +2. Set the necessary `claims` in the `header` section of the vouch-proxy `config.yml` + 1. log in and call the `/validate` endpoint in a modern browser + 2. check the response headers for headers of the form `X-Vouch-IdP-Claims-` + 3. If they are not there clear your cookies and cached browser data + 4. 🐞 If they are still not there but exist in the jwt (esp. custom claims) there might be a bug + 5. remove the `idtoken: X-Vouch-IdP-IdToken` from the `headers` section of vouch-proxy's `config.yml` if you don't need it +3. Use `auth_request_set` after `auth_request` inside the protected location in the nginx [`server.conf`](examples/nginx/nginx_scopes_and_claims.conf) +4. Consume the claim ([example nginx config](examples/nginx/nginx_scopes_and_claims.conf)) + ## Running from Docker -* `./do.sh drun` +```bash +docker run -d \ + -p 9090:9090 \ + --name vouch-proxy \ + -v ${PWD}/config:/config \ + quay.io/vouch/vouch-proxy +``` + +or -And that's it! Or if you can examine the docker command in `do.sh` +```bash +docker run -d \ + -p 9090:9090 \ + --name vouch-proxy \ + -e VOUCH_DOMAINS=yourdomain.com \ + -e OAUTH_PROVIDER=google \ + -e OAUTH_CLIENT_ID=1234 \ + -e OAUTH_CLIENT_SECRET=secretsecret \ + -e OAUTH_CALLBACK_URL=https://vouch.yourdomain.com/auth \ + quay.io/vouch/vouch-proxy +``` -The [bfoote/lasso](https://hub.docker.com/r/bfoote/lasso/) Docker image is an automated build on Docker Hub +As of `v0.36.0` the docker process in the container runs as user `vouch` with UID 999 and GID 999. You may need to set the permissions of `/config/config.yml` and `/config/secret` to correspond to be readable by this user, or otherwise use `docker run --user $UID:$GID ...` or perhaps build the docker container from source and use the available ARGs for UID and GID. -## Running from source +Automated container builds for each Vouch Proxy release are available from [quay.io](https://quay.io/repository/vouch/vouch-proxy). Each release produces.. + +a minimal go binary container built from `Dockerfile` + +- `quay.io/vouch/vouch-proxy:latest` +- `quay.io/vouch/vouch-proxy:x.y.z` such as `quay.io/vouch/vouch-proxy:0.28.0` + +an `alpine` based container built from `Dockerfile.alpine` + +- `quay.io/vouch/vouch-proxy:alpine-latest` +- `quay.io/vouch/vouch-proxy:alpine-x.y.z` + +As of `v0.43.0` both of these images are [Multi-platform builds](https://docs.docker.com/build/building/multi-platform/) supporting `linux/amd64` and `linux/arm64`. + +Vouch Proxy `arm` images are available on [Docker Hub](https://hub.docker.com/r/voucher/vouch-proxy/) + +- `voucher/vouch-proxy:latest-arm` + +## Kubernetes Nginx Ingress + +If you are using kubernetes with [nginx-ingress](https://github.com/kubernetes/ingress-nginx), you can configure your ingress with the following annotations (note quoting the `auth-signin` annotation): + +```bash + nginx.ingress.kubernetes.io/auth-signin: "https://vouch.yourdomain.com/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err" + nginx.ingress.kubernetes.io/auth-url: https://vouch.yourdomain.com/validate + nginx.ingress.kubernetes.io/auth-response-headers: X-Vouch-User + nginx.ingress.kubernetes.io/auth-snippet: | + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + # when VP is hosted externally to k8s ensure the SSL cert is valid to avoid MITM risk + # proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + # proxy_ssl_session_reuse on; + # proxy_ssl_verify_depth 2; + # proxy_ssl_verify on; ``` - go get ./... - go build - ./lasso + +Helm Charts are maintained by [punkle](https://github.com/punkle), [martina-if](https://github.com/martina-if) and [halkeye](https://github.com/halkeye) and are available at [https://github.com/vouch/helm-charts](https://github.com/vouch/helm-charts) + +## Compiling from source and running the binary + +```bash + ./do.sh goget + ./do.sh build + ./vouch-proxy ``` -## the flow of login and authentication using Google Oauth - -* Bob visits `https://private.oursites.com` -* the nginx reverse proxy... - * recieves the request for private.oursites.com from Bob - * uses the `auth_request` module configured for the `/validate` path - * `/validate` is configured to `proxy_pass` requests to the authentication service at `https://lasso.oursites.com/validate` - * if `/validate` returns... - * 200 OK then SUCCESS allow Bob through - * 401 NotAuthorized then - * respond to Bob with a 302 redirect to `https://lasso.oursites.com/login?url=https://private.oursites.com` - -* lasso `https://lasso.oursites.com/validate` - * recieves the request for private.oursites.com from Bob via nginx `proxy_pass` - * it looks for a cookie named "oursitesSSO" that contains a JWT - * if the cookie is found, and the JWT is valid - * returns 200 to nginx, which will allow access (bob notices nothing) - * if the cookie is NOT found, or the JWT is NOT valid - * return 401 NotAuthorized to nginx (which forwards the request on to login) - -* Bob is first forwarded briefly to `https://lasso.oursites.com/login?url=https://private.oursites.com` - * clears out the cookie named "oursitesSSO" if it exists - * generates a nonce and stores it in session variable $STATE - * stores the url `https://private.oursites.com` from the query string in session variable $requestedURL - * respond to Bob with a 302 redirect to Google's OAuth Login form, including the $STATE nonce - -* Bob logs into his Google account using Oauth - * after successful login - * Google responds to Bob with a 302 redirect to `https://lasso.oursites.com/auth?state=$STATE` - -* Bob is forwarded to `https://lasso.oursites.com/auth?state=$STATE` - * if the $STATE nonce from the url matches the session variable "state" - * make a "third leg" request of google (server to server) to exchange the OAuth code for Bob's user info including email address bob@oursites.com - * if the email address matches the domain oursites.com (it does) - * create a user in our database with key bob@oursites.com - * issue bob a JWT in the form of a cookie named "oursitesSSO" - * retrieve the session variable $requestedURL and 302 redirect bob back to $requestedURL - -Note that outside of some innocuos redirection, Bob only ever sees `https://private.oursites.com` and the Google Login screen in his browser. While Lasso does interact with Bob's browser several times, it is just to set cookies, and if the 302 redirects work properly Bob will log in quickly. - -Once the JWT is set, Bob will be authorized for all other sites which are configured to use `https://lasso.oursites.com/validate` from the `auth_request` nginx module. - -The next time Bob is forwarded to google for login, since he has already authorized the site it immediately forwards him back and sets the cookie and sends him on his merry way. Bob may not even notice that he logged in via lasso. +As of `v0.29.0` all templates, static assets and configuration defaults in `.defaults.yml` are built into the static binary using [go:embed](https://pkg.go.dev/embed) directives. + +## /login and /logout endpoint redirection + +As of `v0.11.0` additional checks are in place to reduce [the attack surface of url redirection](https://blog.detectify.com/2019/05/16/the-real-impact-of-an-open-redirect/). + +### /login?url=POST_LOGIN_URL + +The passed URL... + +- must start with either `http` or `https` +- must have a domain overlap with either a domain in the `vouch.domains` list or the `vouch.cookie.domain` (if either of those are configured) +- cannot have a parameter which includes a URL to [prevent URL chaining attacks](https://hackerone.com/reports/202781) + +### /logout?url=NEXT_URL + +The Vouch Proxy `/logout` endpoint accepts a `url` parameter in the query string which can be used to `302` redirect a user to your orignal OAuth provider/IDP/OIDC provider's [revocation_endpoint](https://tools.ietf.org/html/rfc7009) + +```bash + https://vouch.oursites.com/logout?url=https://oauth2.googleapis.com/revoke +``` + +this url must be present in the configuration file on the list `vouch.post_logout_redirect_uris` + +```yaml +# in order to prevent redirection attacks all redirected URLs to /logout must be specified +# the URL must still be passed to Vouch Proxy as https://vouch.yourdomain.com/logout?url=${ONE OF THE URLS BELOW} +post_logout_redirect_uris: + # your apps login page + - https://yourdomain.com/login + # your IdPs logout enpoint + # from https://accounts.google.com/.well-known/openid-configuration + - https://oauth2.googleapis.com/revoke + # you may be daisy chaining to your IdP + - https://myorg.okta.com/oauth2/123serverid/v1/logout?post_logout_redirect_uri=http://myapp.yourdomain.com/login +``` + +Note that your IdP will likely carry their own, separate `post_logout_redirect_uri` list. + +logout resources.. + +- [Google](https://developers.google.com/identity/protocols/OAuth2WebServer#tokenrevoke) +- [Okta](https://developer.okta.com/docs/api/resources/oidc#logout) +- [Auth0](https://auth0.com/docs/logout/guides/logout-idps) + +## Troubleshooting, Support and Feature Requests (Read this before submitting an issue at GitHub) + +Getting the stars to align between Nginx, Vouch Proxy and your IdP can be tricky. We want to help you get up and running as quickly as possible. The most common problem is.. + +### I'm getting an infinite redirect loop which returns me to my IdP (Google/Okta/GitHub/...) + +Double check that you are running Vouch Proxy and your apps on a common domain that can share cookies. For example, `vouch.yourdomain.com` and `app.yourdomain.com` can share cookies on the `.yourdomain.com` domain. (It will not work if you are trying to use `vouch.yourdomain.org` and `app.yourdomain.net`.) + +You may need to explicitly define the domain that the cookie should be set on. You can do this in the config file by setting the option: + +```yaml +vouch: + cookie: + # force the domain of the cookie to set + domain: yourdomain.com +``` + +If you continue to have trouble, try the following: + +- **turn on `vouch.testing: true`**. This will slow down the loop. +- set `vouch.logLevel: debug`. +- the `Host:` header in the http request, the `oauth.callback_url` and the configured `vouch.domains` must all align so that the cookie that carries the JWT can be placed properly into the browser and then returned on each request +- it helps to **_think like a cookie_**. + + - a cookie is set into a domain. If you have `siteA.yourdomain.com` and `siteB.yourdomain.com` protected by Vouch Proxy, you want the Vouch Proxy cookie to be set into `.yourdomain.com` + - if you authenticate to `vouch.yourdomain.com` the cookie will not be able to be seen by `dev.anythingelse.com` + - unless you are using https, you should set `vouch.cookie.secure: false` + - cookies **are** available to all ports of a domain + +- please see the [issues which have been closed that mention redirect](https://github.com/vouch/vouch-proxy/issues?utf8=%E2%9C%93&q=is%3Aissue+redirect+) + +### Okay, I looked at the issues and have tried some things with my configs but it's still not working + +Please [submit a new issue](https://github.com/vouch/vouch-proxy/issues) in the following fashion.. + +TLDR: + +- set `vouch.testing: true` +- set `vouch.logLevel: debug` +- conduct two full round trips of `./vouch-proxy` capturing the output.. + - VP startup + - `/validate` + - `/login` - even if the error is here + - `/auth` + - `/validate` - capture everything +- put all your logs and config in a `gist`. +- `./do.sh bug_report` is your friend + +#### But read this anyways because we'll ask you to read it if you don't follow these instruction. :) + +- **turn on `vouch.testing: true`** and set `vouch.logLevel: debug`. +- use a [gist](https://gist.github.com/) or another **paste service** such as [hasteb.in](https://hasteb.in/). **_DO NOT PUT YOUR LOGS AND CONFIG INTO THE GITHUB ISSUE_**. Using a paste service is important as it will maintain spacing and will provide line numbers and formatting. We are hunting for needles in haystacks with setups with several moving parts, these features help considerably. Paste services save your time and our time and help us to help you quickly. You're more likely to get good support from us in a timely manner by following this advice. +- run `./do.sh bug_report secretdomain.com secretpass [anothersecret..]` which will create a redacted version of your config and logs removing each of those strings + - and follow the instructions at the end to redact your Nginx config +- all of those go into a [gist](https://gist.github.com/) +- then [open a new issue](https://github.com/vouch/vouch-proxy/issues/new) in this repository + +A bug report can be generated from a docker environment using the `quay.io/vouch/vouch-proxy:alpine` image... + +```!bash +docker run --name vouch_proxy -v $PWD/config:/config -v $PWD/certs:/certs -it --rm --entrypoint /do.sh quay.io/vouch/vouch-proxy:alpine bug_report yourdomain.com anotherdomain.com someothersecret +``` + +### Contributing + +We'd love to have you contribute! Please refer to our [contribution guidelines](https://github.com/vouch/vouch-proxy/blob/master/CONTRIBUTING.md) for details. + +## Advanced Authorization Using OpenResty + +OpenResty® is a full-fledged web platform that integrates the standard Nginx core, LuaJIT, many carefully written Lua libraries, lots of high quality 3rd-party Nginx modules, and most of their external dependencies. + +You can replace nginx with [OpenResty](https://openresty.org/en/installation.html) fairly easily. + +With OpenResty and Lua it is possible to provide customized and advanced authorization on any header or claims vouch passes down. + +OpenResty and configs for a variety of scenarios are available in the [examples](https://github.com/vouch/vouch-proxy/tree/master/examples) directory. + +## The flow of login and authentication using Google Oauth + +- Bob visits `https://private.oursites.com` +- the Nginx reverse proxy... + + - recieves the request for private.oursites.com from Bob + - uses the `auth_request` module configured for the `/validate` path + - `/validate` is configured to `proxy_pass` requests to the authentication service at `https://vouch.oursites.com/validate` + - if `/validate` returns... + - 200 OK then SUCCESS allow Bob through + - 401 NotAuthorized then + - respond to Bob with a 302 redirect to `https://vouch.oursites.com/login?url=https://private.oursites.com` + +- Vouch Proxy `https://vouch.oursites.com/validate` + + - recieves the request for private.oursites.com from Bob via Nginx `proxy_pass` + - looks for a cookie named "oursitesSSO" that contains a JWT + - if the cookie is found, and the JWT is valid + - returns `200 OK` to Nginx, which will allow access (bob notices nothing) + - if the cookie is NOT found, or the JWT is NOT valid + - return `401 NotAuthorized` to Nginx (which forwards the request on to login) + +- Bob is first forwarded briefly to `https://vouch.oursites.com/login?url=https://private.oursites.com` + + - clears out the cookie named "oursitesSSO" if it exists + - generates a nonce and stores it in session variable \$STATE + - stores the url `https://private.oursites.com` from the query string in session variable `$requestedURL` + - respond to Bob with a 302 redirect to Google's OAuth Login form, including the `$STATE` nonce + +- Bob logs into his Google account using Oauth + + - after successful login + - Google responds to Bob with a 302 redirect to `https://vouch.oursites.com/auth?state=$STATE` + +- Bob is forwarded to `https://vouch.oursites.com/auth?state=$STATE` + - if the \$STATE nonce from the url matches the session variable "state" + - make a "third leg" request of Google (server to server) to exchange the OAuth code for Bob's user info including email address bob@oursites.com + - if the email address matches the domain oursites.com (it does) + - issue bob a JWT in the form of a cookie named "oursitesSSO" + - retrieve the session variable `$requestedURL` and 302 redirect bob back to `https://private.oursites.com` + +Note that outside of some innocuos redirection, Bob only ever sees `https://private.oursites.com` and the Google Login screen in his browser. While Vouch does interact with Bob's browser several times, it is just to set cookies, and if the 302 redirects work properly Bob will log in quickly. + +Once the JWT is set, Bob will be authorized for all other sites which are configured to use `https://vouch.oursites.com/validate` from the `auth_request` Nginx module. + +The next time Bob is forwarded to google for login, since he has already authorized the Vouch Proxy OAuth app, Google immediately forwards him back and sets the cookie and sends him on his merry way. In some browsers such as Chrome, Bob may not even notice that he logged in using Vouch Proxy. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..1308f7bc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Reporting a Vulnerability + +If you have discovered a vulnerability in Vouch Proxy DO NOT post an issue on GitHub. + +Please do email the maintainers at vouch-proxy-security@bnf.net + +We will respond in short order (usually within a day or two). diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 579f89b8..00000000 --- a/TODO.md +++ /dev/null @@ -1,133 +0,0 @@ -## questions for golang meetup - -* how do I populate the context with the return code for later logging? -* where should I put my pkgs? - -## TODO - -* create a special team for admins - -* look for the token in an "Authorization: bearer $TOKEN" header - -* restapi - * `/api/validate` endpoint that *any* service can connect to that validates the `X-LASSO-TOKEN` header - -* add lastupdate to user, sites, team - -* how to handle "not authorized for domain"? - * can nginx pass a 302 back to /login with an argument in the querystring such as.. - /login?jwt=$COOKIE - yes it can! using the auth_request_set $variable value; - `auth_request_set $auth_lasso_redirect $upstream_http_lasso_redirect` - http://nginx.org/en/docs/http/ngx_http_auth_request_module.html#auth_request_set - * but we're forgetting about the round trip from the state login and setting the cookie - * we just need to detect if we've been here several times in a row, using state and then provide some kind of auth error - * try three times, then provide auth error - - -* issue tokens manually for webhooks - * any of these are valid.. - * http cookie contents - * X-Lasso-Token: ${TOKEN} - * Authorization: Bearer ${TOKEN} - * ?lasso-token=${TOKEN} - * TODO is this the order that these are evaluated in? - * tokens are special - * set the "issuer" field to the user - * does user exist? - * set expires on date in the future - * record the token in the database - * how do we revoke the token? - * blacklist tokens - * add to the conf file - -* limit claims to the domain which the cookie will be placed in - - * who should get issued the token? - * the user? - * pobably yes - * how do we validate the token - -* if the user is forwarded to /login a few times, we need to provide some explanation, and offer them an escaltion path or some way forward - -* move binaries under a cmd/ subdirectory -* user management - * twitter bootstrap - * js build environment -* Docker container that's not Dockerfile.fromscratch -* graphviz of Bob visit flow -* additional validations (like what?) - -## DONE - -* replace gin.Cookie with gorilla.cookie - -* optionally compress the cookie (gzip && base64) -* use url.QueryEscape() instead of base64 https://golang.org/pkg/net/url/#QueryEscape, or maybe use QueryEscape after base64 -* can we stuff all the user/sites into a 4093 byte cookie, or perhaps a cookie half that size to leave room for other cookies - a quick test shows that a raw jwt at 1136 bytes can be gzip and base64 compressed to 471 bytes ~/tmp/jwttests - that is probably worth doing - http://stackoverflow.com/questions/4164276/storing-compressed-data-in-a-cookie#13675023 - -* validate the users' domain against `hd` from google response -* move library code under a pkg/ subdirectory - -/auth validate jwt at - -* is jwt in cookie - * present? - * valid including a user - - no.. redirect to login - -* is user - * authed for the resource? - - no.. redirect to login - -* is domain - * valid? matches authoritative domains (meedan.com, meedan.net, checkmedia.org) - * present in the auth system - no.. notify admin for additional assignment - -/login login & auth - -* offer login -* is user - * exists? no.. - * create user - * assign default roles (based on domain or other heuristic) - * notify admin for additional auth - then.. - yes.. - * issue jwt into a cookie for each domain using an image - -## leaving teams out of this for now - -/admin/domains domain rights - -* authorize roles -* authorize users - -/admin/roles role assignment - -* create roles -* assign users to roles - -## interfaces - -* User - -## TODO - -* websocket api - * `getusers` - * `getteams` - * `createteam` - * `addusertoteam` - * `removeuserfromteam` - * `getsites` - * `addsitetoteam` - * `removesitefromteam` - * `gettokens` - * `createtoken` diff --git a/config/config.yml_example b/config/config.yml_example index 8db4cc42..2c1fdead 100644 --- a/config/config.yml_example +++ b/config/config.yml_example @@ -1,78 +1,325 @@ +# Vouch Proxy configuration -# lasso config +# you should probably start with one of the other example configs in this directory +# Vouch Proxy does a fairly good job of setting its config to sane defaults +# be aware of the yaml indentation, the only top level elements are `vouch` and `oauth`. -lasso: - # logLevel: debug +# Vouch Proxy can also be configured using Environmental Variables. The associated env var for +# each configuration is shown such as VOUCH_LOGLEVEL. + +vouch: + # logLevel: debug # VOUCH_LOGLEVEL logLevel: info - listen: 0.0.0.0 - port: 9090 - # each of these domains must serve the url https://lasso.$domains[0] https://lasso.$domains[1] ... - # usually you'll just have one + + # testing: false - VOUCH_TESTING + # force all 302 redirects to be rendered as a webpage with a link + # if you're having problems, turn on testing + testing: true + + listen: 0.0.0.0 # VOUCH_LISTEN + port: 9090 # VOUCH_PORT + + # Listen can specify a Unix domain socket instead. + # listen: unix:/path/to/socket # VOUCH_LISTEN + + # Optionally set the mode of the Unix domain socket. The default if not specified is 0777. + # socket_mode: 0770 # VOUCH_SOCKETMODE + + # Optionally set the group owner of the Unix domain socket. + # socket_group: users # VOUCH_SOCKETGROUP + + # The default read, write and idle timeouts are 15 seconds. + # If you have a load balancer or proxy in front that has its + # own idle timeout, you may need to ensure that the Vouch idle + # timeout is longer than the proxy's, to avoid intermittent + # 502 errors. + # See https://github.com/vouch/vouch-proxy/issues/317 for more + # information. + writeTimeout: 15 # VOUCH_WRITETIMEOUT + readTimeout: 15 # VOUCH_READTIMEOUT + idleTimeout: 15 # VOUCH_IDLETIMEOUT + + # document_root - VOUCH_DOCUMENT_ROOT + # see README for `Vouch Proxy "in a path"` - https://github.com/vouch/vouch-proxy#vouch-proxy-in-a-path + # document_root: vp_in_a_path + + # domains - VOUCH_DOMAINS + # each of these domains must serve the url https://vouch.$domains[0] https://vouch.$domains[1] ... + # so that the cookie which stores the JWT can be set in the relevant domain + # you usually *don't* want to list every individual website that will be protected + # if you have siteA.internal.yourdomain.com and siteB.internal.yourdomain.com + # then your domains should be set as yourdomain.com or perhaps internal.yourdomain.com + # usually you'll just have one. + # Comment `domains:` out if you set allowAllUser:true domains: - yourdomain.com - yourotherdomain.com - # gets sent to google as a primer + + # Set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider - VOUCH_ALLOWALLUSERS + # allowAllUsers: false + # vouch.cookie.domain must be set below when enabling allowAllUsers + + # Setting publicAccess: true will accept all requests, even without a valid jwt/cookie. - VOUCH_PUBLICACCESS + # If the user is logged in, the cookie will be validated and the user header will be set. + # You will need to direct people to the Vouch Proxy login page from your application. + # publicAccess: false + + # whiteList (optional) allows only the listed usernames - VOUCH_WHITELIST + # usernames are usually email addresses (google, most oidc providers) or login/username for github and github enterprise + whiteList: + - bob@yourdomain.com + - alice@yourdomain.com + - joe@yourdomain.com + + # teamWhitelist - VOUCH_TEAMWHITELIST + # only used for github orgs/teams + # teamWhitelist: + # - vouch + # - myOrg + # - myOrg/myTeam + + tls: + # cert: /path/to/signed_cert_plus_intermediates # VOUCH_TLS_CERT + # key: /path/to/private_key # VOUCH_TLS_KEY + # profile - defines the TLS configuration profile (modern, intermediate, old, default) + profile: intermediate # VOUCH_TLS_PROFILE + jwt: - issuer: Lasso - maxAge: 240 + # signing_method: the algorithm used to sign the JWT. # VOUCH_JWT_SIGNING_METHOD + # Can be one of HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512 + # Default is HS256 (HMAC) - and requires jwt.secret to be set + # Both RS* (RSA) and ES* (ECDSA) methods require jwt.private_key_file and + # jwt.public_key_file to be set. + # signing_method: HS256 + + # secret - VOUCH_JWT_SECRET + # a random string used to cryptographically sign the jwt when signing_method is set to HS256, HS384 or HS512 + # Vouch Proxy complains if the string is less than 44 characters (256 bits as 32 base64 bytes) + # if the secret is not set here then Vouch Proxy will.. + # - look for the secret in `./config/secret` + # - if `./config/secret` doesn't exist then randomly generate a secret and store it there + # in order to run multiple instances of vouch on multiple servers (perhaps purely for validating the jwt), + # you'll want them all to have the same secret secret: your_random_string - compress: true + + # Path to the public/private key files when using an RSA or ECDSA signing method. + # public_key_file: # VOUCH_JWT_PUBLIC_KEY_FILE + # private_key_file: # VOUCH_JWT_PRIVATE_KEY_FILE + + # issuer: Vouch # VOUCH_JWT_ISSUER + + # number of minutes until jwt expires - VOUCH_JWT_MAXAGE + maxAge: 240 + + # compress the jwt - VOUCH_JWT_COMPRESS + compress: true + cookie: - # name of cookie to store the jwt - name: Lasso - secure: false - httpOnly: true - headers: - sso: X-Lasso-Token - redirect: X-Lasso-Requested-URI - db: - file: data/lasso_bolt.db + # name of cookie to store the jwt - VOUCH_COOKIE_NAME + name: VouchCookie + + # optionally force the domain of the cookie to set + # domain: yourdomain.com # VOUCH_COOKIE_DOMAIN + + # Set `secure: false` when protecting a non-https site such as http://app.yourdmain.com - VOUCH_COOKIE_SECURE + secure: true + + # httpOnly: true # VOUCH_COOKIE_HTTPONLY + + # Number of minutes until session cookie expires - VOUCH_COOKIE_MAXAGE + # Set cookie maxAge to 0 to delete the cookie every time the browser is closed. + # Must not be longer than jwt.maxAge + maxAge: 240 + + # Set SameSite attribute to restrict browser behaviour wrt sending the cookie along with cross-site requests. - VOUCH_COOKIE_SAMESITE + # Possible attribute values lax, strict, none. + # If attribute not specified then cross-site behaviour will depend on the browser used. If sameSite=none then secure must be set to true + # More context: https://github.com/vouch/vouch-proxy/issues/210 + sameSite: lax + session: - name: lasso - test_url: http://my.testing.site.com + # name of session variable stored locally - VOUCH_SESSION_NAME + name: VouchSession + # number of minutes for maximum session age, configuring how long the user has to login at their IdP (defaults to 5) - VOUCH_SESSION_MAXAGE + maxAge: 5 + # key - a cryptographic string used to store the session variable - VOUCH_SESSION_KEY + # if the key is not set here then it is generated at startup and stored in memory + # Vouch Proxy complains if the string is less than 44 characters (256 bits as 32 base64 bytes) + # you only want to set this if you're running multiple user facing vouch.yourdomain.com instances + # where each instance may rely on a session cookie for state or the original requested URL + # key: your_random_key + + + headers: + jwt: X-Vouch-Token # VOUCH_HEADERS_JWT + querystring: access_token # VOUCH_HEADERS_QUERYSTRING + redirect: X-Vouch-Requested-URI # VOUCH_HEADERS_REDIRECT + + # GENERAL WARNING ABOUT claims AND tokens + # all of these config elements can cause performance impacts due to the amount of information being + # moved around. They will get added to the Vouch cookie and (possibly) make it large. The Vouch cookie will + # get split up into several cookies. But if you need it, you need it. + # With large cookies and headers it will require additional nginx config to open up the buffers a bit.. + # see `large_client_header_buffers` http://nginx.org/en/docs/http/ngx_http_core_module.html#large_client_header_buffers + # and `proxy_buffer_size` http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size + + # claims - a list of claims that will be stored in the JWT and passed down to applications via headers - VOUCH_HEADERS_CLAIMS + # By default claims are sent down as headers with a prefix of X-Vouch-IdP-Claims-ClaimKey + # Only when a claim is found in the user's info will the header exist. This is optional. These are case sensitive. + claims: + - groups + - given_name + # these will result in two headers being passed back to nginx as the headers + # X-Vouch-IdP-Claims-Groups: groupa, groupb, groupc + # X-Vouch-IdP-Claims-Given-Name: Robert + # nginx will populate the variables + # $auth_resp_x_vouch_idp_claims_groups + # $auth_resp_x_vouch_idp_claims_given-name + # see https://github.com/vouch/vouch-proxy/issues/183 regarding claims and header naming + + # claimheader - Customizable claim header prefix (instead of default `X-Vouch-IdP-Claims-`) - VOUCH_HEADERS_CLAIMHEADER + # claimheader: My-Custom-Claim-Prefix + + # accesstoken - Pass the user's access token from the provider. This is useful if you need to pass the IdP token to a downstream - VOUCH_HEADERS_ACCESSTOKEN + # application. This is optional. + # accesstoken: X-Vouch-IdP-AccessToken + # idtoken - Pass the user's Id token from the provider. This is useful if you need to pass this token to a downstream - VOUCH_HEADERS_IDTOKEN + # application. This is optional. + # idtoken: X-Vouch-IdP-IdToken + + # test_url - add this URL to the page which vouch displays during testing (a convenience for testing) - VOUCH_TESTURL + test_url: http://yourdomain.com + + # post_logout_redirect_uris - VOUCH_POST_LOGOUT_REDIRECT_URIS + # in order to prevent redirection attacks all redirected URLs to /logout must be specified + # the URL must still be passed to Vouch Proxy as https://vouch.yourdomain.com/logout?url=${ONE OF THE URLS BELOW} + # in line with the OIDC spec https://openid.net/specs/openid-connect-session-1_0.html#RedirectionAfterLogout + post_logout_redirect_uris: + # your apps login page + - http://myapp.yourdomain.com/login + # your IdPs logout enpoint + # from https://accounts.google.com/.well-known/openid-configuration + - https://oauth2.googleapis.com/revoke + # you may be daisy chaining to your IdP + - https://myorg.okta.com/oauth2/123serverid/v1/logout?post_logout_redirect_uri=http://myapp.yourdomain.com/login + +# +# OAuth # -# OAuth Config + +# environmental variables for OAuth config: +# provider: OAUTH_PROVIDER +# client_id: OAUTH_CLIENT_ID +# client_secret: OAUTH_CLIENT_SECRET +# auth_url: OAUTH_AUTH_URL +# token_url: OAUTH_TOKEN_URL +# end_session_endpoint: OAUTH_END_SESSION_ENDPOINT +# callback_url: OAUTH_CALLBACK_URL +# user_info_url: OAUTH_USER_INFO_URL +# user_team_url: OAUTH_USER_TEAM_URL +# user_org_url: OAUTH_USER_ORG_URL +# preferreddomain: OAUTH_PREFERREDDOMAIN +# callback_urls: OAUTH_CALLBACK_URLS +# scopes: OAUTH_SCOPES +# claims: OAUTH_CLAIMS +# code_challenge_method: OAUTH_CODE_CHALLENGE_METHOD +# relying_party_id OAUTH_RELYING_PARTY_ID + # +# configure ONLY ONE of the following oauth providers +# + oauth: - # configure one of the following - google: - # create new credentials at: - # https://console.developers.google.com/apis/credentials - client_id: - client_secret: - # must be the /auth endpoint - callback_urls: - - http://lasso.yourdomain.com:9090/auth - - http://lasso.yourotherdomain.com:9090/auth - preferredDomain: yourdomain.com - generic: - # create new credentials at: - # https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/ - provider: github - client_id: - client_secret: - auth_url: https://github.com/login/oauth/authorize - token_url: https://github.com/login/oauth/access_token - # callback_url is configured at github.com when setting up the app - scopes: - - user - user_info_url: https://api.github.com/user?access_token= - generic: - # https://indieauth.com/developers - provider: indieauth - client_id: http://yourdomain.com - # must be the /auth endpoint - auth_url: https://indieauth.com/auth - user_info_url: https://indieauth.com/auth - callback_url: http://lasso.yourdomain.com:9090/auth - generic: - # https://indieauth.com/developers - provider: indieauth - client_id: http://yourdomain.com - # must be the /auth endpoint - auth_url: https://indieauth.com/auth - user_info_url: https://indieauth.com/auth - callback_url: http://lasso.yourdomain.com:9090/auth - + + # Google + provider: google + # create new credentials at: + # https://console.developers.google.com/apis/credentials + client_id: + client_secret: + callback_urls: + - http://vouch.yourdomain.com:9090/auth + - http://vouch.yourotherdomain.com:9090/auth + preferredDomain: yourdomain.com + # optionally set scopes, defaults to 'email' + # https://developers.google.com/identity/protocols/googlescopes#google_sign-in + # scopes: + # - email + + # GitHub + # https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/ + provider: github + client_id: + client_secret: + # callback_url is configured at github.com when setting up the app + # Set to e.g. https://vouch.yourdomain.com/auth + # defaults (uncomment and change these if you are using github enterprise on-prem) + # auth_url: https://github.com/login/oauth/authorize + # token_url: https://github.com/login/oauth/access_token + # user_info_url: https://api.github.com/user?access_token= + # scopes: + # - user + + # Generic OpenID Connect + provider: oidc + client_id: + client_secret: + auth_url: https://{yourOktaDomain}/oauth2/default/v1/authorize + token_url: https://{yourOktaDomain}/oauth2/default/v1/token + user_info_url: https://{yourOktaDomain}/oauth2/default/v1/userinfo + # end_session_endpoint is usually the IdP's logout URL + # see https://github.com/vouch/vouch-proxy/pull/258 + end_session_endpoint: https://{yourOktaDomain}/oauth2/default/v1/logout + scopes: + - openid + - email + - profile + callback_url: http://vouch.yourdomain.com:9090/auth + # optionally set the "claims" request parameter (see https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter) + # required by Twitch, resolves issue https://github.com/vouch/vouch-proxy/issues/414 + # claims: + # userinfo: + # given_name: + # essential: true + # nickname: null + # email: + # essential: true + # email_verified: + # essential: true + # picture: null + # "http://example.info/claims/groups": null + # id_token: + # auth_time: + # essential: true + # acr: + # values: + # - "urn:mace:incommon:iap:silver" + # PKCE method if enabled, S256 is currently supported (check https://www.oauth.com/oauth2-servers/pkce/) + # resolves issue https://github.com/vouch/vouch-proxy/issues/303 + code_challenge_method: S256 + + # IndieAuth + # https://indielogin.com/api + provider: indieauth + client_id: http://yourdomain.com + auth_url: https://indielogin.com/auth + callback_url: http://vouch.yourdomain.com:9090/auth + + # adfs + provider: adfs + client_id: + client_secret: + auth_url: https://adfs.yourdomain.com/adfs/oauth2/authorize/ + token_url: https://adfs.yourdomain.com/adfs/oauth2/token/ + # vouch-proxy use RedirectURL as relying party identifier by default, if you want a custom one: + # see https://github.com/vouch/vouch-proxy/issues/189 + # relying_party_id: 487d8ff7-80a8-4f62-b926-c2852ab06e94 + scopes: + - openid + - email + - profile + callback_url: https://vouch.yourdomain.com/auth + + diff --git a/config/config.yml_example_adfs b/config/config.yml_example_adfs new file mode 100644 index 00000000..03da4720 --- /dev/null +++ b/config/config.yml_example_adfs @@ -0,0 +1,28 @@ +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with adfs + +vouch: + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate to ADFS + allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + # secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + +oauth: + provider: adfs + client_id: k8s + client_secret: sauceSecret + auth_url: https://adfs.yourdomain.com/adfs/oauth2/authorize/ + token_url: https://adfs.yourdomain.com/adfs/oauth2/token/ + # vouch-proxy use RedirectURL as relying party identifier by default, if you want a custom one: + # see https://github.com/vouch/vouch-proxy/issues/189 + # relying_party_id: 487d8ff7-80a8-4f62-b926-c2852ab06e94 + scopes: + - openid + - email + - profile + callback_url: https://vouch.yourdomain.com/auth \ No newline at end of file diff --git a/config/config.yml_example_azure b/config/config.yml_example_azure new file mode 100644 index 00000000..71496aa9 --- /dev/null +++ b/config/config.yml_example_azure @@ -0,0 +1,26 @@ +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Azure AD +# https://github.com/vouch/vouch-proxy/issues/290 + +vouch: + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate to Azure AD + allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + # secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + provider: azure + client_id: 123456789 + client_secret: ******** + auth_url: https://login.microsoftonline.com/.../oauth2/v2.0/authorize + token_url: https://login.microsoftonline.com/.../oauth2/v2.0/token + scopes: + - openid + - email + - profile + callback_url: https://vouch.yourdomain/auth + azure_token: id_token # access_token and id_token supported \ No newline at end of file diff --git a/config/config.yml_example_discord b/config/config.yml_example_discord new file mode 100644 index 00000000..3a251f7e --- /dev/null +++ b/config/config.yml_example_discord @@ -0,0 +1,34 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Discord as an OpenID Provider + + +vouch: + domains: + - yourdomain.com + + # whiteList is a list of usernames that will allow a login if allowAllUsers is false + whiteList: + # The default behavior matches the Discord user's username + - loganintech + + # If the user still hasn't chosen a new username, the old username#discrimnator format will work + - LoganInTech#1203 + + # If discord_use_ids is set to true, you must use the user's ID + - 12345678901234567 + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +# https://discord.com/developers/docs/topics/oauth2 +oauth: + provider: discord + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + callback_url: http://vouch.yourdomain.com:9090/auth + ## Uncomment this to match users based on their Discord ID + # discord_use_ids: true diff --git a/config/config.yml_example_gitea b/config/config.yml_example_gitea new file mode 100644 index 00000000..3f805f93 --- /dev/null +++ b/config/config.yml_example_gitea @@ -0,0 +1,29 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Gitea + +vouch: + domains: + - yourdomain.com + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at Gitea + # allowAllUsers: true + + # cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + # secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + +oauth: + # replace "gitea.yourdomain.com" with the domain your Gitea instance runs on + # create a new OAuth application at: + # https://gitea.yourdomain.com/user/settings/applications + provider: oidc + client_id: xxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://gitea.yourdomain.com/login/oauth/authorize + token_url: https://gitea.yourdomain.com/login/oauth/access_token + user_info_url: https://gitea.yourdomain.com/login/oauth/userinfo + callback_url: https://yourdomain.com/auth diff --git a/config/config.yml_example_github b/config/config.yml_example_github new file mode 100644 index 00000000..25c4a68b --- /dev/null +++ b/config/config.yml_example_github @@ -0,0 +1,45 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with github + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + # for github that's only one domain since they only allow one callback URL + # https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#redirect-urls + # each of these domains must serve the url https://login.$domains[0] https://login.$domains[1] ... + domains: + - yourothersite.io + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at GitHub + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + + # set teamWhitelist: to list of teams and/or GitHub organizations + # When putting an organization id without a slash, it will allow all (public) members from the organization. + # The client will try to read the private organization membership using the client credentials, if that's not possible + # due to access restriction, it will try to evaluate the publicly visible membership. + # Allowing members form a specific team can be configured by qualifying the team with the organization, separated by + # a slash. + # teamWhitelist: + # - myOrg + # - myOrg/myTeam + # In case both vouch.teamWhitelist AND oauth.scopes is configured, make sure read:org scope is included + +oauth: + # create a new OAuth application at: + # https://github.com/settings/applications/new + # + # callback_url is configured at github.com when setting up the app + # Set to e.g. https://vouch.yourdomain.com/auth or https://yourdomain.com/vp_in_a_path/auth + provider: github + client_id: xxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + # endpoints set from https://godoc.org/golang.org/x/oauth2/github diff --git a/config/config.yml_example_github_enterprise b/config/config.yml_example_github_enterprise new file mode 100644 index 00000000..75463904 --- /dev/null +++ b/config/config.yml_example_github_enterprise @@ -0,0 +1,53 @@ +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with github enterprise +# see config.yml_example for all options + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # each of these domains must serve the url https://login.$domains[0] https://login.$domains[1] ... + # the callback_urls will be to these domains + domains: + - yourdomain.com + - yourothersite.io + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + + # set teamWhitelist: to list of teams and/or GitHub organizations + # When putting an organization id without a slash, it will allow all (public) members from the organization. + # The client will try to read the private organization membership using the client credentials, if that's not possible + # due to access restriction, it will try to evaluate the publicly visible membership. + # Allowing members form a specific team can be configured by qualifying the team with the organization, separated by + # a slash. + # teamWhitelist: + # - myOrg + # - myOrg/myTeam + # In case both vouch.teamWhitelist AND oauth.scopes is configured, make sure read:org scope is included + +oauth: + # create a new OAuth application at: + # https://githubenterprise.yourdomain.com/settings/applications/new + provider: github + client_id: xxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://githubenterprise.yourdomain.com/login/oauth/authorize + token_url: https://githubenterprise.yourdomain.com/login/oauth/access_token + user_info_url: https://githubenterprise.yourdomain.com/api/v3/user?access_token= + # relevant only if teamWhitelist is configured; colon-prefixed parts are parameters that + # will be replaced with the respective values. + user_team_url: https://githubenterprise.yourdomain.com/api/v3/orgs/:org_id/teams/:team_slug/memberships/:username?access_token= + user_org_url: https://githubenterprise.yourdomain.com/api/v3/orgs/:org_id/members/:username?access_token= + # these GitHub OAuth defaults are set for you.. + # scopes: + # - user + # In case both vouch.teamWhitelist AND oauth.scopes is configured, make sure read:org scope is included diff --git a/config/config.yml_example_gitlab_ce b/config/config.yml_example_gitlab_ce new file mode 100644 index 00000000..70cf8a1c --- /dev/null +++ b/config/config.yml_example_gitlab_ce @@ -0,0 +1,35 @@ +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with GitLab CE Authentication + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + domains: + - yourdomain.com + - yourotherdomain.com + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # Create a new global or group application and paste the id and the secret + provider: oidc + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + scopes: + - openid + - email + - profile + auth_url: https://gitlab.yourdomain.com/oauth/authorize + token_url: https://gitlab.yourdomain.com/oauth/token + user_info_url: https://gitlab.yourdomain.com/oauth/userinfo + callback_url: http://vouch.yourdomain.com:9090/auth diff --git a/config/config.yml_example_google b/config/config.yml_example_google new file mode 100644 index 00000000..4afaa5d9 --- /dev/null +++ b/config/config.yml_example_google @@ -0,0 +1,31 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with google + +vouch: + domains: + - yourdomain.com + - yourotherdomain.com + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate with Google + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + +oauth: + provider: google + # get credentials from... + # https://console.developers.google.com/apis/credentials + client_id: xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + # Google may require callback_urls (redirect URIs) to be 'https' + callback_urls: + - https://yourdomain.com:9090/auth + - https://yourotherdomain.com:9090/auth + preferredDomain: yourdomain.com # be careful with this option, it may conflict with chrome on Android + # endpoints are set from https://godoc.org/golang.org/x/oauth2/google diff --git a/config/config.yml_example_homeassistant b/config/config.yml_example_homeassistant new file mode 100644 index 00000000..bbe860e2 --- /dev/null +++ b/config/config.yml_example_homeassistant @@ -0,0 +1,37 @@ +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with HomeAssistant + +vouch: + # logLevel: debug + logLevel: info + + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + domains: + - yourdomain.com + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at Gitea + # allowAllUsers: true + + # cookie: + # secure: false # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + # domain: yourdomain.com # vouch.cookie.domain must be set when enabling allowAllUsers + + + # whiteList - (optional) allows only the listed usernames + # usernames are usually email addresses (google, most oidc providers) or login/username for github and github enterprise + # using static value for HomeAssistant + whiteList: + - homeassistant + +oauth: + # HomeAssistant Auth + # HomeAssistant typically uses a port in the url (8123 by default) and this maybe required for the auth_url and token_url + # depending on your setup of HA + # https://developers.home-assistant.io/docs/en/auth_api.html + provider: homeassistant + client_id: https://vouch.yourdomain.com + callback_url: https://vouch.yourdomain.com/auth + auth_url: https://homeassistant.yourdomain.com:port/auth/authorize + token_url: https://homeassistant.yourdomain.com:port/auth/token diff --git a/config/config.yml_example_indieauth b/config/config.yml_example_indieauth new file mode 100644 index 00000000..b229ce65 --- /dev/null +++ b/config/config.yml_example_indieauth @@ -0,0 +1,30 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with IndieAuth + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + # domains: + # - yourdomain.com + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + # Setting publicAccess: true will accept all requests, even without a cookie. + publicAccess: true + +oauth: + # IndieAuth + # https://indielogin.com/api + provider: indieauth + client_id: http://yourdomain.com + auth_url: https://indielogin.com/auth + callback_url: http://vouch.yourdomain.com:9090/auth diff --git a/config/config.yml_example_keycloak b/config/config.yml_example_keycloak new file mode 100644 index 00000000..b176c6aa --- /dev/null +++ b/config/config.yml_example_keycloak @@ -0,0 +1,39 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Keycloak + +vouch: + domains: + - yourdomain.com + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # and set vouch.cookie.domain to the domain you wish to protect + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # Generic OpenID Connect + # for Keycloak + provider: oidc + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://{yourKeycloakDomain}/realms/{yourKeycloakRealm}/protocol/openid-connect/auth + token_url: https://{yourKeycloakDomain}/realms/{yourKeycloakRealm}/protocol/openid-connect/token + user_info_url: https://{yourKeycloakDomain}/realms/{yourKeycloakRealm}/protocol/openid-connect/userinfo + scopes: + - openid + - email + - profile + callback_url: http://vouch.yourdomain.com:9090/auth + # you can get values of of auth_url, token_url and user_info_url from https://{yourKeycloakDomain}/realms/{yourKeycloakRealm}/.well-known/openid-configuration + # When configuring client in Keycloak, you should use following values + ## valid redirect: http://vouch.yourdomain.com:9090/auth + ## valid logout: http://vouch.yourdomain.com:9090/logout + ## web origin: http://vouch.yourdomain.com:9090 \ No newline at end of file diff --git a/config/config.yml_example_nextcloud b/config/config.yml_example_nextcloud new file mode 100644 index 00000000..349c8457 --- /dev/null +++ b/config/config.yml_example_nextcloud @@ -0,0 +1,37 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Nextcloud Authentication + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + domains: + - yourdomain.com + - yourotherdomain.com + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # This assumes usage of pretty URLs otherwise add /index.php/ + # to start of URL path + provider: nextcloud + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://nextcloud.yourdomain.com/apps/oauth2/authorize + token_url: https://nextcloud.yourdomain.com/apps/oauth2/api/v1/token + user_info_url: https://nextcloud.yourdomain.com/ocs/v2.php/cloud/user?format=json + scopes: + - openid + - email + - profile + callback_url: http://vouch.yourdomain.com:9090/auth diff --git a/config/config.yml_example_oidc b/config/config.yml_example_oidc new file mode 100644 index 00000000..8e99bdfb --- /dev/null +++ b/config/config.yml_example_oidc @@ -0,0 +1,38 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with OpenID Connect (such as okta) + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + domains: + - yourdomain.com + - yourotherdomain.com + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # and set vouch.cookie.domain to the domain you wish to protect + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # Generic OpenID Connect + # including okta + provider: oidc + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://{yourOktaDomain}/oauth2/default/v1/authorize + token_url: https://{yourOktaDomain}/oauth2/default/v1/token + user_info_url: https://{yourOktaDomain}/oauth2/default/v1/userinfo + scopes: + - openid + - email + - profile + callback_url: http://vouch.yourdomain.com:9090/auth diff --git a/config/config.yml_example_pocket-id b/config/config.yml_example_pocket-id new file mode 100644 index 00000000..f733b190 --- /dev/null +++ b/config/config.yml_example_pocket-id @@ -0,0 +1,41 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with pocket-id + +# Pocket ID +# https://pocket-id.org +# https://github.com/pocket-id/pocket-id + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + domains: + - yourdomain.com + - yourotherdomain.com + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # and set vouch.cookie.domain to the domain you wish to protect + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # pocket-id + provider: oidc + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://{yourPocketIdDomain}/authorize + token_url: https://{yourPocketIdDomain}/api/oidc/token + user_info_url: https://{yourPocketIdDomain}/api/oidc/userinfo + scopes: + - openid + - email + - profile + callback_url: http://vouch.{yourdomain.com}/auth diff --git a/config/config.yml_example_scopes_and_claims b/config/config.yml_example_scopes_and_claims new file mode 100644 index 00000000..757b5eb8 --- /dev/null +++ b/config/config.yml_example_scopes_and_claims @@ -0,0 +1,23 @@ +vouch: + # .... domain configuration goes here + + headers: + # The idtoken is used for debugging during configuration + # use the idtoken to make sure the oauth provider returns the necessary claims + idtoken: X-Vouch-IdP-IdToken + + # make sure to list all the claims you need + # Note: they will be stored in the cookie AND header and get sent with each request + claims: + - sub + - name + - email + - email_verified + +oauth: + # .... your provider config goes here + scopes: + # make sure to set the required scopes + - openid + - email + - profile diff --git a/config/config.yml_example_secureauth b/config/config.yml_example_secureauth new file mode 100644 index 00000000..3f22572b --- /dev/null +++ b/config/config.yml_example_secureauth @@ -0,0 +1,30 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with SecureAuth OpenID Connect + +vouch: + domains: + - yourdomain.com + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at Gitea + # allowAllUsers: true + + # cookie: + # secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # SecureAuth OpenID Connect + provider: oidc + client_id: XXXXXXXXXXXXXXX + client_secret: XXXXXXXXXXXXXXXXXXXXXX + auth_url: https:///SecureAuth/SecureAuth.aspx + token_url: https:///SecureAuth/OidcToken.aspx + user_info_url: https:///SecureAuth/OidcUserInfo.aspx + scopes: + - openid + - email + - profile + # callback_url needs to be set as a "redirect url" on the SecureAuth Post Auth tab + callback_url: https://./auth diff --git a/config/config.yml_example_slack b/config/config.yml_example_slack new file mode 100644 index 00000000..e9286982 --- /dev/null +++ b/config/config.yml_example_slack @@ -0,0 +1,33 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Slack + +vouch: + domains: + - yourdomain.com + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at Gitea + # allowAllUsers: true + + # cookie: + # secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + + +oauth: + # create a new OAuth application at: + # https://api.slack.com/apps + # use the manifest at `examples/slack/vouch-slack-oidc-app-manifest.yml` + # but be sure to match the `callback_url`'s below to the `redirect_urls` in the manifest + # then install the new app to your slack instance + provider: oidc + # careful! the slack client_id must be single quoted so that the yaml parser + # doesn't interpret it as a number (because yaml is actually javascript) + client_id: 'xxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx' + client_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + callback_url: https://vouch.yourdomain.com/auth + # from https://slack.com/.well-known/openid-configuration + auth_url: https://slack.com/openid/connect/authorize + token_url: https://slack.com/api/openid.connect.token + user_info_url: https://slack.com/api/openid.connect.userInfo diff --git a/config/config.yml_example_twitch b/config/config.yml_example_twitch new file mode 100644 index 00000000..98e61bf9 --- /dev/null +++ b/config/config.yml_example_twitch @@ -0,0 +1,43 @@ + +# Vouch Proxy configuration +# bare minimum to get Vouch Proxy running with Twitch + +vouch: + # domains: + # valid domains that the jwt cookies can be set into + # the callback_urls will be to these domains + domains: + - yourdomain.com + - yourotherdomain.com + + # - OR - + # instead of setting specific domains you may prefer to allow all users... + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider + # and set vouch.cookie.domain to the domain you wish to protect + # allowAllUsers: true + + cookie: + # allow the jwt/cookie to be set into http://yourdomain.com (defaults to true, requiring https://yourdomain.com) + # secure: false + # vouch.cookie.domain must be set when enabling allowAllUsers + # domain: yourdomain.com + +oauth: + # Generic OpenID Connect + provider: oidc + client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://id.twitch.tv/oauth2/authorize + token_url: https://id.twitch.tv/oauth2/token + user_info_url: https://id.twitch.tv/oauth2/userinfo + callback_url: https://vouch.yourdomain.com/auth + scopes: + - openid + - user:read:email + # Twitch uses the claims parameter to configure the information returned via `user_info_url` + claims: + userinfo: + email: + essential: true + email_verified: + essential: true diff --git a/config/testing/handler_allowallusers.yml b/config/testing/handler_allowallusers.yml new file mode 100644 index 00000000..bb45b0c7 --- /dev/null +++ b/config/testing/handler_allowallusers.yml @@ -0,0 +1,11 @@ +vouch: + allowAllUsers: true + + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_claims.yml b/config/testing/handler_claims.yml new file mode 100644 index 00000000..a426f732 --- /dev/null +++ b/config/testing/handler_claims.yml @@ -0,0 +1,29 @@ +vouch: + testing: true + logLevel: debug + listen: 0.0.0.0 + port: 9090 + + allowAllUsers: true + + headers: + claims: + - groups + - boolean_claim + - family_name + - http://www.example.com/favorite_color + + cookie: + name: vouchTestingCookie + + session: + name: VouchTestingSession + + jwt: + secret: testing + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_email.yml b/config/testing/handler_email.yml new file mode 100644 index 00000000..88878c0f --- /dev/null +++ b/config/testing/handler_email.yml @@ -0,0 +1,13 @@ +vouch: + logLevel: error + domains: + - example.com + + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_login_redirecturls.yml b/config/testing/handler_login_redirecturls.yml new file mode 100644 index 00000000..c29fa794 --- /dev/null +++ b/config/testing/handler_login_redirecturls.yml @@ -0,0 +1,19 @@ +vouch: + domains: + - example.com + - other.com + + cookie: + secure: false + + jwt: + secret: testingsecret + +oauth: + provider: google + client_id: 1234567 + client_secret: testingsecret + auth_url: https://indielogin.com/auth + callback_urls: + - http://vouch.example.com:9090/auth + - http://vouch.other.com:9090/auth diff --git a/config/testing/handler_login_url.yml b/config/testing/handler_login_url.yml new file mode 100644 index 00000000..a69a405f --- /dev/null +++ b/config/testing/handler_login_url.yml @@ -0,0 +1,16 @@ +vouch: + domains: + - example.com + + cookie: + secure: false + domain: example.com + + jwt: + secret: testingsecret + +oauth: + provider: google + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_login_url_document_root.yml b/config/testing/handler_login_url_document_root.yml new file mode 100644 index 00000000..27be44bc --- /dev/null +++ b/config/testing/handler_login_url_document_root.yml @@ -0,0 +1,17 @@ +vouch: + document_root: /vouch_in_a_path + domains: + - example.com + + cookie: + secure: false + domain: example.com + + jwt: + secret: testingsecret + +oauth: + provider: google + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_logout_provider.yml b/config/testing/handler_logout_provider.yml new file mode 100644 index 00000000..d0aaf08b --- /dev/null +++ b/config/testing/handler_logout_provider.yml @@ -0,0 +1,21 @@ +vouch: + domains: + - example.com + + cookie: + secure: false + + jwt: + secret: testingsecret + + post_logout_redirect_uris: + - http://myapp.example.com/login + # https://accounts.google.com/.well-known/openid-configuration + - https://oauth2.googleapis.com/revoke + +oauth: + provider: google + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth + end_session_endpoint: https://indielogin.com/logout diff --git a/config/testing/handler_logout_url.yml b/config/testing/handler_logout_url.yml new file mode 100644 index 00000000..cc6bd8db --- /dev/null +++ b/config/testing/handler_logout_url.yml @@ -0,0 +1,20 @@ +vouch: + domains: + - example.com + + cookie: + secure: false + + jwt: + secret: testingsecret + + post_logout_redirect_uris: + - http://myapp.example.com/login + # https://accounts.google.com/.well-known/openid-configuration + - https://oauth2.googleapis.com/revoke + +oauth: + provider: google + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_nodomains.yml b/config/testing/handler_nodomains.yml new file mode 100644 index 00000000..ff6185d0 --- /dev/null +++ b/config/testing/handler_nodomains.yml @@ -0,0 +1,9 @@ +vouch: + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_teams.yml b/config/testing/handler_teams.yml new file mode 100644 index 00000000..6048b2df --- /dev/null +++ b/config/testing/handler_teams.yml @@ -0,0 +1,17 @@ +vouch: + logLevel: debug + domains: + - example.com + + teamWhitelist: + - "org1/team1" + - "org1/team2" + + jwt: + secret: testingsecret + +oauth: + provider: github + client_id: fake + auth_url: fake + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_whitelist.yml b/config/testing/handler_whitelist.yml new file mode 100644 index 00000000..c73ef898 --- /dev/null +++ b/config/testing/handler_whitelist.yml @@ -0,0 +1,16 @@ +vouch: + logLevel: debug + domains: + - example.com + + whiteList: + - test@example.com + + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/jwtmanager_has_idp_token_claims.yml b/config/testing/jwtmanager_has_idp_token_claims.yml new file mode 100644 index 00000000..ed0e0e31 --- /dev/null +++ b/config/testing/jwtmanager_has_idp_token_claims.yml @@ -0,0 +1,31 @@ +vouch: + testing: true + logLevel: debug + listen: 0.0.0.0 + port: 9090 + + allowAllUsers: true + + headers: + claims: + - groups + - boolean_claim + - family_name + - http://www.example.com/favorite_color + accesstoken: X-Vouch-IdP-AccessToken + idtoken: X-Vouch-IdP-IdToken + + cookie: + name: vouchTestingCookie + + session: + name: VouchTestingSession + + jwt: + secret: testing + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/logging_debug.yml b/config/testing/logging_debug.yml new file mode 100644 index 00000000..d6902969 --- /dev/null +++ b/config/testing/logging_debug.yml @@ -0,0 +1,9 @@ +vouch: + logLevel: debug + allowAllUsers: true + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/logging_error.yml b/config/testing/logging_error.yml new file mode 100644 index 00000000..598052c2 --- /dev/null +++ b/config/testing/logging_error.yml @@ -0,0 +1,9 @@ +vouch: + logLevel: error + allowAllUsers: true + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/logging_info.yml b/config/testing/logging_info.yml new file mode 100644 index 00000000..ab124170 --- /dev/null +++ b/config/testing/logging_info.yml @@ -0,0 +1,9 @@ +vouch: + logLevel: info + allowAllUsers: true + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/logging_warn.yml b/config/testing/logging_warn.yml new file mode 100644 index 00000000..dd84473b --- /dev/null +++ b/config/testing/logging_warn.yml @@ -0,0 +1,9 @@ +vouch: + logLevel: warn + allowAllUsers: true + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/minimal.yml b/config/testing/minimal.yml new file mode 100644 index 00000000..5c948d0b --- /dev/null +++ b/config/testing/minimal.yml @@ -0,0 +1,8 @@ +vouch: + allowAllUsers: true + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/socket_basic.yml b/config/testing/socket_basic.yml new file mode 100644 index 00000000..c7cc4500 --- /dev/null +++ b/config/testing/socket_basic.yml @@ -0,0 +1,8 @@ +vouch: + listen: unix:/fake/path/to/socket + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/socket_mode.yml b/config/testing/socket_mode.yml new file mode 100644 index 00000000..b784f522 --- /dev/null +++ b/config/testing/socket_mode.yml @@ -0,0 +1,9 @@ +vouch: + listen: unix:/fake/path/to/socket + socket_mode: 0644 + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/test_config.yml b/config/testing/test_config.yml new file mode 100644 index 00000000..8143b4f6 --- /dev/null +++ b/config/testing/test_config.yml @@ -0,0 +1,26 @@ +vouch: + logLevel: debug + listen: 0.0.0.0 + port: 9090 + domains: + - vouch.github.io + + whiteList: + - bob@yourdomain.com + - alice@yourdomain.com + - joe@yourdomain.com + + cookie: + name: vouchTestingCookie + + session: + name: VouchTestingSession + + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/test_config_oauth_claims.yml b/config/testing/test_config_oauth_claims.yml new file mode 100644 index 00000000..8ccf03de --- /dev/null +++ b/config/testing/test_config_oauth_claims.yml @@ -0,0 +1,48 @@ +vouch: + logLevel: debug + listen: 0.0.0.0 + port: 9090 + domains: + - vouch.github.io + + whiteList: + - bob@yourdomain.com + - alice@yourdomain.com + - joe@yourdomain.com + + cookie: + name: vouchTestingCookie + + session: + name: VouchTestingSession + + jwt: + secret: testingsecret + +oauth: + provider: oidc + auth_url: https://oauth2.example.info/authorize + token_url: https://oauth2.example.info/token + user_info_url: https://oauth2.example.info/userinfo + scopes: + - openid + - email + - profile + claims: + userinfo: + given_name: + essential: true + nickname: null + email: + essential: true + email_verified: + essential: true + picture: null + "http://example.info/claims/groups": null + id_token: + auth_time: + essential: true + acr: + values: + - "urn:mace:incommon:iap:silver" + callback_url: https://vouch.yourdomain.com:9090/auth diff --git a/config/testing/test_config_rsa.yml b/config/testing/test_config_rsa.yml new file mode 100644 index 00000000..d865c8ed --- /dev/null +++ b/config/testing/test_config_rsa.yml @@ -0,0 +1,28 @@ +vouch: + logLevel: debug + listen: 0.0.0.0 + port: 9090 + domains: + - vouch.github.io + + whiteList: + - bob@yourdomain.com + - alice@yourdomain.com + - joe@yourdomain.com + + cookie: + name: vouchTestingCookie + + session: + name: VouchTestingSession + + jwt: + signing_method: RS512 + private_key_file: config/testing/rsa.key + public_key_file: config/testing/rsa.pub + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/do.sh b/do.sh index 779d51cf..b1baa801 100755 --- a/do.sh +++ b/do.sh @@ -1,4 +1,5 @@ -#!/bin/bash +#!/usr/bin/env bash +set -e # change dir to where this script is running CURDIR=${PWD} @@ -6,166 +7,445 @@ SCRIPT=$(readlink -f "$0") SDIR=$(dirname "$SCRIPT") cd $SDIR -export LASSO_ROOT=${GOPATH}/src/github.com/bnfinet/lasso/ +if [ -z "$VOUCH_ROOT" ]; then + export VOUCH_ROOT=${GOPATH}/src/github.com/vouch/vouch-proxy/ +fi -IMAGE=bfoote/lasso -GOIMAGE=golang:1.8 -NAME=lasso +IMAGE=quay.io/vouch/vouch-proxy:latest +ALPINE=quay.io/vouch/vouch-proxy:alpine-latest +GOIMAGE=golang:1.26 +NAME=vouch-proxy HTTPPORT=9090 GODOC_PORT=5050 -gogo () { - docker run --rm -i -t -v /var/run/docker.sock:/var/run/docker.sock -v ${SDIR}/go:/go --name gogo $GOIMAGE $* +run () { + go run main.go } -revproxy () { - /home/bfoote/files/docker/bnfinet/dockerfiles/bnfnet/lasso-nginx-test/run_docker.sh $* +build () { + local VERSION=$(git describe --always --long) + local DT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # ISO-8601 + local UQDN=$(_hostname) + local SEMVER=$(git tag --list --sort="v:refname" | tail -n -1) + local BRANCH=$(git rev-parse --abbrev-ref HEAD) + local UNAME=$(uname) + go build -v -ldflags=" -X main.version=${VERSION} -X main.uname=${UNAME} -X main.builddt=${DT} -X main.host=${UQDN} -X main.semver=${SEMVER} -X main.branch=${BRANCH}" . +} + +_hostname() { + local FQDN + local UQDN + FQDN=$(hostname) + UQDN=${FQDN/.*/} + + if [ -z "$UQDN" ]; then + >&2 echo "error: Could determine the fully qualified domain name." + return 1 + fi + echo "$UQDN" + return 0; +} + +install () { + cp ./vouch-proxy ${GOPATH}/bin/vouch-proxy +} + +gogo () { + docker run --rm -i -t -v /var/run/docker.sock:/var/run/docker.sock -v ${SDIR}/go:/go --name gogo $GOIMAGE $* } dbuild () { docker build -f Dockerfile -t $IMAGE . } +dbuildalpine () { + docker build -f Dockerfile.alpine -t $ALPINE . +} + gobuildstatic () { - CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + export CGO_ENABLED=0 + export GOOS=linux + build } drun () { - if [ "$(docker ps | grep $NAME)" ]; then - docker stop $NAME - docker rm $NAME - fi - - CMD="docker run --rm -i -t - -p ${HTTPPORT}:${HTTPPORT} - --name $NAME - -v ${SDIR}/config:/config - -v ${SDIR}/data:/data + if [ "$(docker ps | grep $NAME)" ]; then + docker stop $NAME + docker rm $NAME + fi + WITHCERTS="" + if [ -d "${SDIR}/certs" ] && [ -z $(find ${SDIR}/certs -type d -empty) ]; then + WITHCERTS="-v ${SDIR}/certs:/certs" + fi + + + CMD="docker run --rm -i -t + -p ${HTTPPORT}:${HTTPPORT} + --name $NAME + -v ${SDIR}/config:/config + $WITHCERTS $IMAGE $* " echo $CMD $CMD } +drunalpine () { + IMAGE=$ALPINE + drun $* +} + watch () { - CMD=$@; - if [ -z "$CMD" ]; then - CMD="go run main.go" - fi - clear - echo -e "starting watcher for:\n\t$CMD" - $CMD & - WATCH_PID=$! - echo WATCH_PID $WATCH_PID - # FIRST_TIME=1 - while inotifywait -q --exclude *.db --exclude './.git/FETCH_HEAD' --exclude do.sh -e modify -r .; do - if [ -n "$WATCH_PID" ]; then - echo "killing $WATCH_PID and restarting $CMD" - kill $WATCH_PID - sleep 3 - fi - echo -e "\n---restart---\n" - $CMD & - WATCH_PID=$! - echo WATCH_PID $WATCH_PID - done; + CMD=$@; + if [ -z "$CMD" ]; then + CMD="go run main.go" + fi + clear + echo -e "starting watcher for:\n\t$CMD" + + # TODO: add *.tmpl and *.css + # find . -type f -name '*.css' | entr -cr $CMD + find . -name '*.go' | entr -cr $CMD } goget () { # install all the things - go get -v ./... + go get -u -v ./... + go mod tidy } -test () { + +REDACT="" +bug_report() { + set +e + # CONFIG=$1; shift; + CONFIG=config/config.yml + REDACT=$* + + if [ -z "$REDACT" ]; then + cat <&1 | _redact + +} + +_redact_exit () { + echo -e "\n\n-------------------------\n" + echo -e "redact your nginx config with:\n" + echo -e "\tcat nginx.conf | sed 's/yourdomain.com/DOMAIN.COM/g'\n" + echo -e "Please upload configs and logs to a gist and open an issue on GitHub at https://github.com/vouch/vouch-proxy/issues\n" +} + +_redact() { + SECRET_FIELDS=("client_id client_secret secret ClientSecret ClientID") + while IFS= read -r LINE; do + for i in $SECRET_FIELDS; do + LINE=$(echo "$LINE" | sed -r "s/${i}..[[:graph:]]*\>/${i}: XXXXXXXXXXX/g") + done + # j=$(expr $j + 1) + for i in $REDACT; do + r=$i + r=$(echo "$r" | sed "s/[[:alnum:]]/+/g") + # LINE=$(echo "$LINE" | sed "s/${i}/+++++++/g") + LINE=$(echo "$LINE" | sed "s/${i}/${r}/g") + done + echo "${LINE}" + done +} + +coverage() { + mkdir -p .cover && go test -v -coverprofile=.cover/cover.out ./... +} + +coveragereport() { + go tool cover -html=.cover/cover.out -o .cover/coverage.html +} + +test() { + export SKIPPERFTEST=true; + _tests +} + +test_perf() { + _tests +} + +_tests() { + if [ -z "$VOUCH_CONFIG" ]; then + export VOUCH_CONFIG="$SDIR/config/testing/test_config.yml" + fi + + TEST_PRIVATE_KEY_FILE="$SDIR/config/testing/rsa.key" + TEST_PUBLIC_KEY_FILE="$SDIR/config/testing/rsa.pub" + if [[ ! -f "$TEST_PRIVATE_KEY_FILE" ]]; then + openssl genrsa -out "$TEST_PRIVATE_KEY_FILE" 4096 + fi + if [[ ! -f "$TEST_PUBLIC_KEY_FILE" ]]; then + openssl rsa -in "$TEST_PRIVATE_KEY_FILE" -pubout > "$TEST_PUBLIC_KEY_FILE" + fi + + go get -t ./... # test all the things if [ -n "$*" ]; then - go test -v $* + # go test -v -race $EXTRA_TEST_ARGS $* + go test -race $EXTRA_TEST_ARGS $* else - go test -v ./... + # go test -v -race $EXTRA_TEST_ARGS ./... + go test -race $EXTRA_TEST_ARGS ./... fi } -graphviz () { -# FILE=$1; shift; - FILE=lasso_flow.dot; - CMD="docker run - --rm - -it - -v $(pwd):/code - -w /code - --entrypoint dot - themarquee/graphviz - -T png - -o lasso_flow.png - $FILE -" - echo $CMD - ${CMD} +test_logging() { + build + + declare -a levels=(error warn info debug) + + echo "testing loglevel set from command line" + levelcount=0 + for ll in ${levels[*]}; do + # test that we can see the current level and no level below this level + + declare -a shouldnotfind=() + for (( i=0; i<${#levels[@]}; i++ )); do + if (( $i > $levelcount )); then + shouldnotfind+=(${levels[$i]}) + fi + done + + linesread=0 + IFS=$'\n';for line in $(./vouch-proxy -logtest -loglevel ${ll} -config ./config/testing/test_config.yml); do + let "linesread+=1" + # echo "$linesread $line" + # first line is log info + if (( $linesread > 1 )); then + for nono in ${shouldnotfind[*]} ; do + if echo $line | grep $nono; then + echo "error: line should not contain '$nono' when loglevel is '$ll'" + echo "$linesread $line" + exit 1; + fi + done + fi + done + let "levelcount+=1" + done + echo "passed" + + echo "testing loglevel set from config file" + levelcount=0 + for ll in ${levels[*]}; do + # test that we can see the current level and no level below this level + declare -a shouldnotfind=() + for (( i=0; i<${#levels[@]}; i++ )); do + if (( $i > $levelcount )); then + shouldnotfind+=(${levels[$i]}) + fi + done + + linesread=0 + IFS=$'\n';for line in $(./vouch-proxy -logtest -config ./config/testing/logging_${ll}.yml); do + let "linesread+=1" + # the first four messages are log and info when starting from the command line + if (( $linesread > 4 )); then + # echo "$linesread $line" + for nono in ${shouldnotfind[*]} ; do + # echo "testing $nono" + if echo $line | grep $nono; then + echo "error: line should not contain '$nono' when loglevel is '$ll'" + echo "$linesread $line" + exit 1; + fi + done + fi + done + let "levelcount+=1" + + done + echo "passed" + exit 0 +} + +stats () { + echo -n "lines of code: " + find . -name '*.go' | xargs wc -l | grep total + + echo -n "number of go files: " + find . -name '*.go' | wc -l + + echo -n "number of covered packages: " + covered=$(coverage | grep ok | wc -l) + echo $covered + echo -n "number of packages not covered: " + coverage | grep -v ok | wc -l + echo -n "average of coverage for all covered packages: " + sumcoverage=$(coverage | grep ok | awk '{print $5}' | sed 's/%//' | paste -sd+ - | bc) + # echo " sumcoverage: $sumcoverage " + perl -le "print $sumcoverage/$covered, '%'" + exit 0; } -DB=data/lasso_bolt.db -browsebolt() { - ~/go/bin/boltbrowser $DB +license() { + local FILE=$1; + if [ ! -f "${FILE}" ]; then + echo "need filename"; + exit 1; + fi + FOUND=$(_has_license $FILE) + if [ -z "${FOUND}" ]; then + local YEAR=$(git log -1 --format="%ai" -- $FILE | cut -d- -f1); + _print_license $YEAR > ${FILE}_licensed + cat $FILE >> ${FILE}_licensed + mv ${FILE}_licensed $FILE + echo "added license to the header of $FILE" + fi + + # and then format the codebase + gofmt + +} + +_print_license() { + local YEAR=$1; + if [ -z "$YEAR" ]; then + YEAR=$(date +%Y) + fi + cat < lasso_flow.jpg + $0 run - go run main.go + $0 build - go build + $0 install - move binary to ${GOPATH}/bin/vouch + $0 goget - get all dependencies + $0 gofmt - gofmt the entire code base + $0 gosec - gosec security audit of the entire code base + $0 selfcert - calls openssl to create a self signed key and cert + $0 dbuild - build docker container + $0 drun [args] - run docker container + $0 dbuildalpine - build docker container for alpine + $0 drunalpine [args] - run docker container for alpine + $0 test [./pkg_test.go] - run go tests (defaults to NOT run performance tests) + $0 test_perf - run go tests including performance tests + $0 test_logging - test the logging output + $0 coverage - coverage test + $0 coveragereport - coverage report published to .cover/coverage.html + $0 profile - go pprof tools + $0 bug_report domain.com [badstr2..] - print config file and log removing secrets and each provided string + $0 gogo [gocmd] - run, build, any go cmd + $0 stats - simple metrics (lines of code in project, number of go files) + $0 watch [cmd] - watch the \$CWD for any change and re-reun the [cmd] (defaults to 'go run main.go') + $0 license [file] - apply the license to the file do is like make EOF + exit 1 } -ARG=$1; shift; +ARG=$1; case "$ARG" in - 'browsebolt') - browsebolt - ;; - - 'build') - dbuild - ;; - 'drun') - drun $* - ;; - 'revproxy') - revproxy $* - ;; - 'graphviz') - graphviz $* - ;; - 'test') - test $* + 'run' \ + |'build' \ + |'dbuild' \ + |'drun' \ + |'dbuildalpine' \ + |'drunalpine' \ + |'install' \ + |'test' \ + |'goget' \ + |'selfcert' \ + |'gogo' \ + |'watch' \ + |'gobuildstatic' \ + |'coverage' \ + |'coveragereport' \ + |'stats' \ + |'usage' \ + |'bug_report' \ + |'test_perf' \ + |'test_logging' \ + |'license' \ + |'profile' \ + |'gosec' \ + |'gofmt') + shift + $ARG $* ;; 'godoc') echo "godoc running at http://${GODOC_PORT}" godoc -http=:${GODOC_PORT} ;; - 'goget'|'get') - goget $* - ;; - 'gogo') - gogo $* - ;; - 'watch') - watch $* - ;; - 'gobuildstatic') - gobuildstatic $* - ;; 'all') + shift gobuildstatic dbuild drun $* diff --git a/examples/OpenResty/README.md b/examples/OpenResty/README.md new file mode 100644 index 00000000..8a4c0e73 --- /dev/null +++ b/examples/OpenResty/README.md @@ -0,0 +1,22 @@ +# Advanced Authorization Using OpenResty + +## What is OpenResty? +OpenResty® is a full-fledged web platform that integrates the standard Nginx core, LuaJIT, many carefully written Lua libraries, lots of high quality 3rd-party Nginx modules, and most of their external dependencies. + +## Instructions + +You can replace nginx with OpenResty very easily. OpenResty installation documents can be found [here](https://openresty.org/en/installation.html). + +The following configuration files demonstrate a front-end proxy with multiple backend applications that are authenticated using various methods. + +| File | Description | +| :--- | :--- | +| conf/nginx.conf | Only the generic nginx config without any 'server' fields. It includes anything at ../conf.d/*.conf | +| lua/group_auth.lua | A lua file that validates a list of groups against the values in X-Vouch-IdP-Claims-Groups. | +| lua/user_auth.lua | A lua file that validates a list of users against the value in X-Vouch-User. | +| conf.d/app1.yourdomain.com.conf | Configuration for an authenticated application at https://app1.yourdomain.com. Uses user authorization. | +| conf.d/app2.yourdomain.com.conf | Configuration for an authenticated application at https://app2.yourdomain.com. Uses group authorization. This file can be duplicated for every application you'd like to deploy. | +| conf.d/unauthenticated_app3.yourdomain.com.conf | A simple configuration for an unauthenticated application or page. This could be a terms of service, license, or generic help page. It could also be some application or API endpoint that you simply don't want to authenticate. | +| conf.d/vouch.yourdomain.com.conf | Configuration for exposing vouch at the proxy using https to a vouch instance on localhost. This configuration supports secure cookies. | + +With OpenResty and Lua it is possible to provide customized and advanced authorization on any header or claims vouch passes down. diff --git a/examples/OpenResty/conf.d/app1.yourdomain.com.conf b/examples/OpenResty/conf.d/app1.yourdomain.com.conf new file mode 100644 index 00000000..911e2d1a --- /dev/null +++ b/examples/OpenResty/conf.d/app1.yourdomain.com.conf @@ -0,0 +1,68 @@ +server { + listen 443 ssl http2; + server_name app1.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/app1.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app1.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # Vouch Proxy can run behind the same Nginx reverse proxy + # may need to comply to "upstream" server naming + proxy_pass https://vouch.yourdomain.com:9090/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # optionally add X-Vouch-IdP-Claims-* custom claims you are tracking + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + # optinally add X-Vouch-IdP-AccessToken or X-Vouch-IdP-IdToken + # auth_request_set $auth_resp_x_vouch_idp_accesstoken $upstream_http_x_vouch_idp_accesstoken; + # auth_request_set $auth_resp_x_vouch_idp_idtoken $upstream_http_x_vouch_idp_idtoken; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://vouch.yourdomain.com/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + # proxy pass authorized requests to your service + location / { + proxy_pass http://app1-private.yourdomain.com:8080; + # may need to set + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + # proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_groups; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; + + # Authenticate the application by user + access_by_lua_file lua/user_auth.lua; + } +} diff --git a/examples/OpenResty/conf.d/app2.yourdomain.com.conf b/examples/OpenResty/conf.d/app2.yourdomain.com.conf new file mode 100644 index 00000000..dfc464b1 --- /dev/null +++ b/examples/OpenResty/conf.d/app2.yourdomain.com.conf @@ -0,0 +1,68 @@ +server { + listen 443 ssl http2; + server_name app2.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/app2.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app2.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # Vouch Proxy can run behind the same Nginx reverse proxy + # may need to comply to "upstream" server naming + proxy_pass https://vouch.yourdomain.com/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # optionally add X-Vouch-IdP-Claims-* custom claims you are tracking + auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_group; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + # optinally add X-Vouch-IdP-AccessToken or X-Vouch-IdP-IdToken + # auth_request_set $auth_resp_x_vouch_idp_accesstoken $upstream_http_x_vouch_idp_accesstoken; + # auth_request_set $auth_resp_x_vouch_idp_idtoken $upstream_http_x_vouch_idp_idtoken; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://vouch.yourdomain.com/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + # proxy pass authorized requests to your service + location / { + proxy_pass http://app2-private.yourdomain.com:8080; + # may need to set + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_group; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; + + # Authenticate the application by group + access_by_lua_file lua/group_auth.lua; + } +} diff --git a/examples/OpenResty/conf.d/unauthenticated_app3.yourdown.com.conf b/examples/OpenResty/conf.d/unauthenticated_app3.yourdown.com.conf new file mode 100644 index 00000000..06784a89 --- /dev/null +++ b/examples/OpenResty/conf.d/unauthenticated_app3.yourdown.com.conf @@ -0,0 +1,14 @@ +server { + listen 443 ssl http2; + server_name app3.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/app3.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app3.yourdomain.com/privkey.pem; + + + # This application is simply proxy-passed without any authentication + location / { + proxy_pass http://app3-private.yourdomain.com:8080; + } +} diff --git a/examples/OpenResty/conf.d/vouch.yourdomain.com.conf b/examples/OpenResty/conf.d/vouch.yourdomain.com.conf new file mode 100644 index 00000000..fc585572 --- /dev/null +++ b/examples/OpenResty/conf.d/vouch.yourdomain.com.conf @@ -0,0 +1,14 @@ +server { + # Setting vouch behind SSL allows you to use the Secure flag for cookies. + listen 443 ssl http2; + server_name vouch.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/vouch.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vouch.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:9090; + # be sure to pass the original host header + proxy_set_header Host vouch.yourdomain.com; + } +} \ No newline at end of file diff --git a/examples/OpenResty/conf/nginx.conf b/examples/OpenResty/conf/nginx.conf new file mode 100644 index 00000000..200d70a8 --- /dev/null +++ b/examples/OpenResty/conf/nginx.conf @@ -0,0 +1,40 @@ +user nobody; +worker_processes 2; + +error_log /var/log/nginx/error.log warn; +pid logs/nginx.pid; + +events { + worker_connections 1024; +} + + +http { + include mime.types; + default_type application/octet-stream; + lua_code_cache off; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + + keepalive_timeout 65; + init_by_lua_block { + -- Function to find a key in a table + function tableHasKey(table,key) + return table[key] ~= nil + end + -- Function to turn a table with only values into a k=>v table + function Set (list) + local set = {} + for _, l in ipairs(list) do set[l] = true end + return set + end + } + + include ../conf.d/*.conf; +} \ No newline at end of file diff --git a/examples/OpenResty/lua/group_auth.lua b/examples/OpenResty/lua/group_auth.lua new file mode 100644 index 00000000..a86da9a9 --- /dev/null +++ b/examples/OpenResty/lua/group_auth.lua @@ -0,0 +1,42 @@ +-- ============================== +-- Group Authentication +-- via X-Vouch-IdP-Groups +-- ============================== +-- Function to turn a table with only values into a k=>v table +function Set (list) + local set = {} + for _, l in ipairs(list) do set[l] = true end + return set +end +-- Function to find a key in a table +function tableHasKey(table,key) + return table[key] ~= nil +end +-- Validate that a user is in a group +local authorized_groups = Set { + "CN=Domain Users,CN=Users,DC=Contoso,DC=com", + "CN=Website Users,CN=Users,DC=Contoso,DC=com" +} +-- Verify the variable exists +if ngx.var.auth_resp_x_vouch_idp_claims_groups then + -- Check if the found user is in the allowed_users table + local cjson = require("cjson") + local groups = cjson.decode("[" .. ngx.var.auth_resp_x_vouch_idp_claims_groups .. "]") + local found = false + -- Parse the groups and check if they match any of our authorized groups + for i, group in ipairs(groups) do + if tableHasKey(authorized_groups, group) then + -- If we found an authorized group, say so and break the loop + found = true + break + end + end + -- If we didn't find out group in our list, then return forbidden + if not found then + -- If not, throw a forbidden + ngx.exit(ngx.HTTP_FORBIDDEN) + end +else + -- Throw forbidden if variable doesn't exist + ngx.exit(ngx.HTTP_FORBIDDEN) +end diff --git a/examples/OpenResty/lua/user_auth.lua b/examples/OpenResty/lua/user_auth.lua new file mode 100644 index 00000000..e979cbcc --- /dev/null +++ b/examples/OpenResty/lua/user_auth.lua @@ -0,0 +1,30 @@ +-- ============================== +-- User Authentication +-- via X-Vouch-User +-- ============================== +-- Function to turn a table with only values into a k=>v table +function Set (list) + local set = {} + for _, l in ipairs(list) do set[l] = true end + return set +end +-- Function to find a key in a table +function tableHasKey(table,key) + return table[key] ~= nil +end +-- Validate a user in nginx, instead of vouch +local authorized_users = Set { + "my@account.com", + "friend@gmail.com" +} +-- Verify the variable exists +if ngx.var.auth_resp_x_vouch_user then + -- Check if the found user is in the authorized_users table + if not tableHasKey(authorized_users, ngx.var.auth_resp_x_vouch_user) then + -- If not, throw a forbidden + ngx.exit(ngx.HTTP_FORBIDDEN) + end +else + -- Throw forbidden if variable doesn't exist + ngx.exit(ngx.HTTP_FORBIDDEN) +end diff --git a/examples/nginx-vouch-private_appA_appB_appC.png b/examples/nginx-vouch-private_appA_appB_appC.png new file mode 100644 index 00000000..4bb54cce Binary files /dev/null and b/examples/nginx-vouch-private_appA_appB_appC.png differ diff --git a/examples/nginx-vouch-private_simple.png b/examples/nginx-vouch-private_simple.png new file mode 100644 index 00000000..6be5da45 Binary files /dev/null and b/examples/nginx-vouch-private_simple.png differ diff --git a/examples/nginx/README.md b/examples/nginx/README.md new file mode 100644 index 00000000..d9cfba43 --- /dev/null +++ b/examples/nginx/README.md @@ -0,0 +1,11 @@ +# NGINX Examples + +Nginx can be used for most deployments of Vouch Proxy. Nginx is always an appropriate choice unless you wan to do advanced authorization of users based on information being returned from Vouch Proxy. + +## Configuration Examples + +### Single-File +Use the single file examples when you only have one or a small number of applications you would like to proxy. The single file examples are simple and easy to implement. + +### Multi-File +Use the multi-file examples if you want to better organize your configuration files and make it easier to add/remove proxied applications. \ No newline at end of file diff --git a/examples/nginx/multi-file/README.md b/examples/nginx/multi-file/README.md new file mode 100644 index 00000000..2d6e0e30 --- /dev/null +++ b/examples/nginx/multi-file/README.md @@ -0,0 +1,11 @@ +# Nginx Multi-File Configuration Example + +Nginx can be configured to include conf files, allowing you to properly organize nginx configurations into individual apps. This keeps configurations cleaner and easier to manage. + +| File | Description | +| :--- | :--- | +| nginx.conf | Only the generic nginx config without any 'server' fields. It includes anything at conf.d/*.conf | +| conf.d/app1.yourdomain.com.conf | Configuration for an authenticated application at https://app1.yourdomain.com | +| conf.d/app2.yourdomain.com.conf | Configuration for an authenticated application at https://app2.yourdomain.com. This file can be duplicated for every application you'd like to deploy. | +| conf.d/unauthenticated_app3.yourdomain.com.conf | A simple configuration for an unauthenticated application or page. This could be a terms of service, license, or generic help page. It could also be some application or API endpoint that you simply don't want to authenticate. | +| conf.d/vouch.yourdomain.com.conf | Configuration for exposing vouch at the proxy using https to a vouch instance on localhost. This configuration supports secure cookies. | \ No newline at end of file diff --git a/examples/nginx/multi-file/conf.d/app1.yourdomain.com.conf b/examples/nginx/multi-file/conf.d/app1.yourdomain.com.conf new file mode 100644 index 00000000..e208c328 --- /dev/null +++ b/examples/nginx/multi-file/conf.d/app1.yourdomain.com.conf @@ -0,0 +1,49 @@ +server { + listen 443 ssl http2; + server_name app1.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/app1.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app1.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # forward the /validate request to Vouch Proxy + proxy_pass http://127.0.0.1:9090/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://vouch.yourdomain.com:9090/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + # proxy pass authorized requests to your service + location / { + proxy_pass http://app1.yourdomain.com:8080; + # may need to set + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # in this bock as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + } +} diff --git a/examples/nginx/multi-file/conf.d/app2.yourdomain.com.conf b/examples/nginx/multi-file/conf.d/app2.yourdomain.com.conf new file mode 100644 index 00000000..3bbb65be --- /dev/null +++ b/examples/nginx/multi-file/conf.d/app2.yourdomain.com.conf @@ -0,0 +1,49 @@ +server { + listen 443 ssl http2; + server_name app2.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/app2.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app2.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # forward the /validate request to Vouch Proxy + proxy_pass http://127.0.0.1:9090/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://vouch.yourdomain.com:9090/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + # proxy pass authorized requests to your service + location / { + proxy_pass http://app2.yourdomain.com:8080; + # may need to set + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # in this bock as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + } +} diff --git a/examples/nginx/multi-file/conf.d/unauthenticated_app3.yourdown.com.conf b/examples/nginx/multi-file/conf.d/unauthenticated_app3.yourdown.com.conf new file mode 100644 index 00000000..a66483f0 --- /dev/null +++ b/examples/nginx/multi-file/conf.d/unauthenticated_app3.yourdown.com.conf @@ -0,0 +1,14 @@ +server { + listen 443 ssl http2; + server_name app3.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/app3.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app3.yourdomain.com/privkey.pem; + + + # This application is simply proxy-passed without any authentication + location / { + proxy_pass http://app3.yourdomain.com:8080; + } +} diff --git a/examples/nginx/multi-file/conf.d/vouch.yourdomain.com.conf b/examples/nginx/multi-file/conf.d/vouch.yourdomain.com.conf new file mode 100644 index 00000000..fc585572 --- /dev/null +++ b/examples/nginx/multi-file/conf.d/vouch.yourdomain.com.conf @@ -0,0 +1,14 @@ +server { + # Setting vouch behind SSL allows you to use the Secure flag for cookies. + listen 443 ssl http2; + server_name vouch.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/vouch.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vouch.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:9090; + # be sure to pass the original host header + proxy_set_header Host vouch.yourdomain.com; + } +} \ No newline at end of file diff --git a/examples/nginx/multi-file/nginx.conf b/examples/nginx/multi-file/nginx.conf new file mode 100644 index 00000000..9ca12701 --- /dev/null +++ b/examples/nginx/multi-file/nginx.conf @@ -0,0 +1,30 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + + +http { + resolver 127.0.0.1; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + # This line allows you to keep separate configs for multiple applications in a different folder. + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/examples/nginx/nginx_scopes_and_claims.conf b/examples/nginx/nginx_scopes_and_claims.conf new file mode 100644 index 00000000..5681000f --- /dev/null +++ b/examples/nginx/nginx_scopes_and_claims.conf @@ -0,0 +1,33 @@ +server { + listen 80; + server_name mydomain.com; + + location ^~ /sso/validate { + proxy_pass http://vouch:9090/validate; + proxy_set_header Host $http_host; + proxy_pass_request_body off; + } + + location ^~ /api/v1/ { + auth_request /sso/validate; + + # get the claim/s into a local nginx variable + auth_request_set $sub $upstream_http_x_vouch_idp_claims_sub; + auth_request_set $email $upstream_http_x_vouch_idp_claims_email; + auth_request_set $verified $upstream_http_x_vouch_idp_claims_email_verified; + + # forward the claim to the proxied server + proxy_set_header X-sub $sub; + proxy_set_header X-email $email; + proxy_set_header X-email-verified $verified; + + # generic proxy headers + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_redirect off; + proxy_buffering off; + proxy_pass http://api./; + } +} \ No newline at end of file diff --git a/examples/nginx/single-file/README.md b/examples/nginx/single-file/README.md new file mode 100644 index 00000000..d889136b --- /dev/null +++ b/examples/nginx/single-file/README.md @@ -0,0 +1,8 @@ +# Nginx Single-File Configuration Examples + + +| File | Description | +| :--- | :--- | +| nginx_basic.conf | The basic nginx configuration example. Provides authentication for an app at https://protectedapp.yourdomain.com. Vouch is running on vouch.yourdomain.com:9090 directly accessible.| +| nginx_with_vouch.conf | Builds on the basic example by adding a proxy (port 80) for vouch to a vouch instance on localhost. | +| nginx-with_vouch_ssl.conf | Builds on the basic example by adding a proxy (port 443) for vouch using https to a vouch instance on localhost. This configuration supports secure cookies. Multiple backends can listen on port 443 at the same time when using server_name field.| \ No newline at end of file diff --git a/examples/nginx/single-file/nginx_basic.conf b/examples/nginx/single-file/nginx_basic.conf new file mode 100644 index 00000000..285aea4d --- /dev/null +++ b/examples/nginx/single-file/nginx_basic.conf @@ -0,0 +1,89 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + + server { + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # forward the /validate request to Vouch Proxy + proxy_pass http://vouch.yourdomain.com:9090/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 http://vouch.yourdomain.com:9090/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + # you usually *want* to redirect to Vouch running behind the same Nginx config proteced by https + # but to get started you can just forward the end user to the port that vouch is running on + } + + # proxy pass authorized requests to your service + location / { + # forward authorized requests to your service protectedapp.yourdomain.com + proxy_pass http://127.0.0.1:8080; + # you may need to set these variables in this block as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + # proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_groups; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; + } + } + +} diff --git a/examples/nginx/single-file/nginx_with_vouch.conf b/examples/nginx/single-file/nginx_with_vouch.conf new file mode 100644 index 00000000..72207164 --- /dev/null +++ b/examples/nginx/single-file/nginx_with_vouch.conf @@ -0,0 +1,98 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + server { + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # forward the /validate request to Vouch Proxy + proxy_pass http://127.0.0.1:9090/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 http://vouch.yourdomain.com/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + # you usually *want* to redirect to Vouch running behind the same Nginx config proteced by https + # but to get started you can just forward the end user to the port that vouch is running on + } + + # proxy pass authorized requests to your service + location / { + # forward authorized requests to your service protectedapp.yourdomain.com + proxy_pass http://127.0.0.1:8080; + # you may need to set these variables in this block as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + # proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_groups; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; + } + } + + server { + listen 80 default_server; + server_name vouch.yourdomain.com; + location / { + proxy_pass http://127.0.0.1:9090; + # be sure to pass the original host header + proxy_set_header Host vouch.yourdomain.com; + } + } + +} diff --git a/examples/nginx/single-file/nginx_with_vouch_single_server.conf b/examples/nginx/single-file/nginx_with_vouch_single_server.conf new file mode 100644 index 00000000..e0cfb292 --- /dev/null +++ b/examples/nginx/single-file/nginx_with_vouch_single_server.conf @@ -0,0 +1,99 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + upstream vouch { + # set this to location of the vouch proxy + server localhost:9090; + } + + server { + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + + # This location serves all of the paths vouch uses + location ~ ^/(auth|login|logout|static) { + proxy_pass http://vouch; + proxy_set_header Host $http_host; + } + + location = /validate { + # forward the /validate request to Vouch Proxy + proxy_pass http://vouch/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 $scheme://$http_host/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + # you usually *want* to redirect to Vouch running behind the same Nginx config proteced by https + # but to get started you can just forward the end user to the port that vouch is running on + } + + # proxy pass authorized requests to your service + location / { + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + # forward authorized requests to your service protectedapp.yourdomain.com + proxy_pass http://127.0.0.1:8080; + # you may need to set these variables in this block as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + # proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_groups; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; + } + } + +} diff --git a/examples/nginx/single-file/nginx_with_vouch_ssl.conf b/examples/nginx/single-file/nginx_with_vouch_ssl.conf new file mode 100644 index 00000000..8e9cb0c3 --- /dev/null +++ b/examples/nginx/single-file/nginx_with_vouch_ssl.conf @@ -0,0 +1,102 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + + server { + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; + root /var/www/html/; + + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + + # send all requests to the `/validate` endpoint for authorization + auth_request /validate; + + location = /validate { + # forward the /validate request to Vouch Proxy + proxy_pass http://127.0.0.1:9090/validate; + + # be sure to pass the original host header + proxy_set_header Host $http_host; + + # Vouch Proxy only acts on the request headers + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # optionally add X-Vouch-User as returned by Vouch Proxy along with the request + auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + + # these return values are used by the @error401 call + auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt; + auth_request_set $auth_resp_err $upstream_http_x_vouch_err; + auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount; + } + + # if validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://vouch.yourdomain.com/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount&X-Vouch-Token=$auth_resp_jwt&error=$auth_resp_err; + } + + # proxy pass authorized requests to your service + location / { + # forward authorized requests to your service protectedapp.yourdomain.com + proxy_pass http://127.0.0.1:8080; + # you may need to set these variables in this block as per https://github.com/vouch/vouch-proxy/issues/26#issuecomment-425215810 + # auth_request_set $auth_resp_x_vouch_user $upstream_http_x_vouch_user; + # auth_request_set $auth_resp_x_vouch_idp_claims_groups $upstream_http_x_vouch_idp_claims_groups; + # auth_request_set $auth_resp_x_vouch_idp_claims_given_name $upstream_http_x_vouch_idp_claims_given_name; + + # set user header (usually an email) + proxy_set_header X-Vouch-User $auth_resp_x_vouch_user; + # optionally pass any custom claims you are tracking + # proxy_set_header X-Vouch-IdP-Claims-Groups $auth_resp_x_vouch_idp_claims_groups; + # proxy_set_header X-Vouch-IdP-Claims-Given_Name $auth_resp_x_vouch_idp_claims_given_name; + # optionally pass the accesstoken or idtoken + # proxy_set_header X-Vouch-IdP-AccessToken $auth_resp_x_vouch_idp_accesstoken; + # proxy_set_header X-Vouch-IdP-IdToken $auth_resp_x_vouch_idp_idtoken; + } + } + + server { + # Setting vouch behind SSL allows you to use the Secure flag for cookies. + listen 443 ssl http2; + server_name vouch.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/vouch.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vouch.yourdomain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:9090; + # be sure to pass the original host header + proxy_set_header Host vouch.yourdomain.com; + } + } + +} diff --git a/examples/slack/vouch-slack-oidc-app-manifest.yml b/examples/slack/vouch-slack-oidc-app-manifest.yml new file mode 100644 index 00000000..edc3183e --- /dev/null +++ b/examples/slack/vouch-slack-oidc-app-manifest.yml @@ -0,0 +1,16 @@ +_metadata: + major_version: 1 + minor_version: 1 +display_information: + name: Vouch Proxy - Login to Slack + description: enforce login to Slack to provide authorized access to your websites + background_color: "#002da8" +oauth_config: + # these need to match the + redirect_urls: + - https://vouch.yourdomain.com/auth + scopes: + user: + - email + - openid + - profile diff --git a/examples/startup/README.md b/examples/startup/README.md new file mode 100644 index 00000000..6e87e850 --- /dev/null +++ b/examples/startup/README.md @@ -0,0 +1,15 @@ +# Startups Scripts + +If you are running Vouch Proxy on a linux system, instead of docker, you may want to automatically start Vouch Proxy. + +:bangbang: Please note, we highly recommend running Vouch Proxy as a **regular user**. Vouch Proxy listens on port 9090 and doesn't require ANY root privileges. **Please DO NOT run Vouch as root**. + +All provided scripts assume that the compiled Vouch Proxy binary `vouch-proxy` has been installed in `/opt/vouch-proxy` with the executable flag set and owned by `vouch-proxy` user (that has also been created) + +## Systemd + +``` +cp startup/systemd/vouch-proxy.service /etc/systemd/system/vouch-proxy.service +systemctl enable vouch-proxy.service +systemctl start vouch-proxy.service +``` \ No newline at end of file diff --git a/examples/startup/systemd/vouch-proxy.service b/examples/startup/systemd/vouch-proxy.service new file mode 100644 index 00000000..e90be1c6 --- /dev/null +++ b/examples/startup/systemd/vouch-proxy.service @@ -0,0 +1,16 @@ +[Unit] +Description=Vouch Proxy +After=network.target + +[Service] +Type=simple +User=vouch-proxy +WorkingDirectory=/opt/vouch-proxy +ExecStart=/opt/vouch-proxy/vouch-proxy +Restart=on-failure +RestartSec=5 +StartLimitInterval=60s +StartLimitBurst=3 + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..6c8ac1b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module github.com/vouch/vouch-proxy + +go 1.25.0 + +toolchain go1.26.2 + +require ( + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/go-cmp v0.7.0 + github.com/gorilla/sessions v1.4.0 + github.com/julienschmidt/httprouter v1.3.0 + github.com/karupanerura/go-mock-http-response v0.0.0-20171201120521-7c242a447d45 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20220510032225-4f9f17eaec4c + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + github.com/theckman/go-securerandom v0.1.1 + github.com/tsenart/vegeta v12.7.0+incompatible + go.uber.org/zap v1.28.0 + golang.org/x/net v0.53.0 + golang.org/x/oauth2 v0.36.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-gk v0.0.0-20200319235926-a69029f61654 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/influxdata/tdigest v0.0.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/streadway/quantile v0.0.0-20150917103942-b0c588724d25 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..1c68e034 --- /dev/null +++ b/go.sum @@ -0,0 +1,99 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b h1:AP/Y7sqYicnjGDfD5VcY4CIfh1hRXBUavxrvELjTiOE= +github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-gk v0.0.0-20200319235926-a69029f61654 h1:XOPLOMn/zT4jIgxfxSsoXPxkrzz0FaCHwp33x5POJ+Q= +github.com/dgryski/go-gk v0.0.0-20200319235926-a69029f61654/go.mod h1:qm+vckxRlDt0aOla0RYJJVeqHZlWfOm2UIxHaqPB46E= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY= +github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/karupanerura/go-mock-http-response v0.0.0-20171201120521-7c242a447d45 h1:XSik/ETzj52cVbZcv7tJuUFX14XzvRX0te26UaKY0Aw= +github.com/karupanerura/go-mock-http-response v0.0.0-20171201120521-7c242a447d45/go.mod h1:FULZ2B7LE0CUYtI8XLMYxI58AF9M6MTg6nWmZvWoFHQ= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20220510032225-4f9f17eaec4c h1:4RYnE0ISVwRxm9Dfo7utw1dh0kdRDEmVYq2MFVLy5zI= +github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20220510032225-4f9f17eaec4c/go.mod h1:DvuJJ/w1Y59rG8UTDxsMk5U+UJXJwuvUgbiJSm9yhX8= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/streadway/quantile v0.0.0-20150917103942-b0c588724d25 h1:7z3LSn867ex6VSaahyKadf4WtSsJIgne6A1WLOAGM8A= +github.com/streadway/quantile v0.0.0-20150917103942-b0c588724d25/go.mod h1:lbP8tGiBjZ5YWIc2fzuRpTaz0b/53vT6PEs3QuAWzuU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/theckman/go-securerandom v0.1.1 h1:5KctSyM0D5KKFK+bsypIyLq7yik0CEaI5i2fGcUGcsQ= +github.com/theckman/go-securerandom v0.1.1/go.mod h1:bmkysLfBH6i891sBpcP4xRM3XIB7jMeiKJB31jlResI= +github.com/tsenart/vegeta v12.7.0+incompatible h1:sGlrv11EMxQoKOlDuMWR23UdL90LE5VlhKw/6PWkZmU= +github.com/tsenart/vegeta v12.7.0+incompatible/go.mod h1:Smz/ZWfhKRcyDDChZkG3CyTHdj87lHzio/HOCkbndXM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca h1:PupagGYwj8+I4ubCxcmcBRk3VlUWtTg5huQpZR9flmE= +gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 00000000..dcfd69ef --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,180 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "fmt" + "net/http" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/domains" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/responses" + "github.com/vouch/vouch-proxy/pkg/structs" + + "golang.org/x/oauth2" +) + +// CallbackHandler /auth +// - redirects to /auth/{state}/ with the state coming from the query parameter +func CallbackHandler(w http.ResponseWriter, r *http.Request) { + log.Debug("/auth") + + // did the IdP return an error? + errorIDP := r.URL.Query().Get("error") + if errorIDP != "" { + errorDescription := r.URL.Query().Get("error_description") + responses.Error401HTTP(w, r, fmt.Errorf("/auth Error from IdP: %s - %s", errorIDP, errorDescription)) + return + } + + queryState := r.URL.Query().Get("state") + if queryState == "" { + responses.Error400(w, r, fmt.Errorf("/auth: could not find state in query %s", r.URL.RawQuery)) + return + } + + // has to have a trailing / in its path, because the path of the session cookie is set to /auth/{state}/. + // see note in login.go and https://github.com/vouch/vouch-proxy/issues/373 + authStateURL := fmt.Sprintf("%s/auth/%s/?%s", cfg.Cfg.DocumentRoot, queryState, r.URL.RawQuery) + responses.Redirect302(w, r, authStateURL) +} + +// AuthStateHandler /auth/{state}/ +// - validate info from oauth provider (Google, GitHub, OIDC, etc) +// - issue jwt in the form of a cookie +func AuthStateHandler(w http.ResponseWriter, r *http.Request) { + log.Debug("/auth/{state}/") + // Handle the exchange code to initiate a transport. + + session, err := sessstore.Get(r, cfg.Cfg.Session.Name) + if err != nil { + responses.Error400(w, r, fmt.Errorf("/auth %w: could not find session store %s", err, cfg.Cfg.Session.Name)) + return + } + + // is the nonce "state" valid? + queryState := r.URL.Query().Get("state") + if session.Values["state"] != queryState { + responses.Error400(w, r, fmt.Errorf("/auth Invalid session state: stored %s, returned %s", session.Values["state"], queryState)) + return + } + + user := structs.User{} + customClaims := structs.CustomClaims{} + ptokens := structs.PTokens{} + + // is code challenge enabled? + authCodeOptions := []oauth2.AuthCodeOption{} + + if cfg.GenOAuth.CodeChallengeMethod != "" { + authCodeOptions = []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_challenge", session.Values["codeChallenge"].(string)), + oauth2.SetAuthURLParam("code_verifier", session.Values["codeVerifier"].(string)), + } + } + + if err := getUserInfo(r, &user, &customClaims, &ptokens, authCodeOptions...); err != nil { + responses.Error400(w, r, fmt.Errorf("/auth Error while retrieving user info after successful login at the OAuth provider: %w", err)) + return + } + log.Debugf("/auth/{state}/ Claims from userinfo: %+v", customClaims) + + // verify / authz the user + if ok, err := verifyUser(user); !ok { + responses.Error403(w, r, fmt.Errorf("/auth User is not authorized: %w . Please try again or seek support from your administrator", err)) + return + } + + // SUCCESS!! they are authorized + + // issue the jwt + + tokenstring, err := jwtmanager.NewVPJWT(user, customClaims, ptokens) + if err != nil { + responses.Error500(w, r, fmt.Errorf("/auth Token creation failure: %w . Please seek support from your administrator", err)) + return + + } + cookie.SetCookie(w, r, tokenstring) + + // get the originally requested URL so we can send them on their way + requestedURL := session.Values["requestedURL"].(string) + if requestedURL != "" { + // clear out the session value + session.Values["requestedURL"] = "" + session.Values[requestedURL] = 0 + session.Options.MaxAge = -1 + if err = session.Save(r, w); err != nil { + log.Error(err) + } + + responses.Redirect302(w, r, requestedURL) + return + } + + // otherwise serve an error + responses.RenderIndex(w, "/auth "+tokenstring) +} + +// verifyUser validates that the domains match for the user +func verifyUser(u interface{}) (bool, error) { + + user := u.(structs.User) + + switch { + + // AllowAllUsers + case cfg.Cfg.AllowAllUsers: + log.Debugf("verifyUser: Success! skipping verification, cfg.Cfg.AllowAllUsers is %t", cfg.Cfg.AllowAllUsers) + return true, nil + + // WhiteList + case len(cfg.Cfg.WhiteList) != 0: + for _, wl := range cfg.Cfg.WhiteList { + if user.Username == wl { + log.Debugf("verifyUser: Success! found user.Username in WhiteList: %s", user.Username) + return true, nil + } + } + return false, fmt.Errorf("verifyUser: user.Username not found in WhiteList: %s", user.Username) + + // TeamWhiteList + case len(cfg.Cfg.TeamWhiteList) != 0: + for _, team := range user.TeamMemberships { + for _, wl := range cfg.Cfg.TeamWhiteList { + if team == wl { + log.Debugf("verifyUser: Success! found user.TeamWhiteList in TeamWhiteList: %s for user %s", wl, user.Username) + return true, nil + } + } + } + return false, fmt.Errorf("verifyUser: user.TeamMemberships %s not found in TeamWhiteList: %s for user %s", user.TeamMemberships, cfg.Cfg.TeamWhiteList, user.Username) + + // Domains + case len(cfg.Cfg.Domains) != 0: + if domains.IsUnderManagement(user.Email) { + log.Debugf("verifyUser: Success! Email %s found within a %s managed domain", user.Email, cfg.Branding.FullName) + return true, nil + } + return false, fmt.Errorf("verifyUser: Email %s is not within a %s managed domain", user.Email, cfg.Branding.FullName) + + // nothing configured, allow everyone through + default: + log.Warn("verifyUser: no domains, whitelist, teamWhitelist or AllowAllUsers configured, any successful auth to the IdP authorizes access") + return true, nil + } +} + +func getUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) error { + return provider.GetUserInfo(r, user, customClaims, ptokens, opts...) +} diff --git a/handlers/auth_test.go b/handlers/auth_test.go new file mode 100644 index 00000000..f074553c --- /dev/null +++ b/handlers/auth_test.go @@ -0,0 +1,98 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func TestCallbackHandlerDocumentRoot(t *testing.T) { + handlerL := http.HandlerFunc(LoginHandler) + handlerA := http.HandlerFunc(CallbackHandler) + + tests := []struct { + name string + configFile string + wantcode int + }{ + {"should have URL that begins with DocumentRoot", "/config/testing/handler_login_url_document_root.yml", http.StatusFound}, + {"should have URL that does not begin with DocumentRoot", "/config/testing/handler_login_url.yml", http.StatusFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(tt.configFile) + + // first make a request of /login to set the session cookie + reqLogin, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/login?url=http://myapp.example.com/logout", nil) + reqLogin.Header.Set("Host", "my.example.com") + if err != nil { + t.Fatal(err) + } + rrL := httptest.NewRecorder() + handlerL.ServeHTTP(rrL, reqLogin) + + // grab the state from the session cookie to + session, err := sessstore.Get(reqLogin, cfg.Cfg.Session.Name) + state := session.Values["state"].(string) + if err != nil { + t.Fatal(err) + } + + // now mimic an IdP returning the state variable back to us + reqAuth, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/auth?state="+state, nil) + reqAuth.Header.Set("Host", "my.example.com") + if err != nil { + t.Fatal(err) + } + // transfer the cookie from rrL to reqAuth + rrA := httptest.NewRecorder() + + handlerA.ServeHTTP(rrA, reqAuth) + if rrA.Code != tt.wantcode { + t.Errorf("LoginHandler() status = %v, want %v", rrA.Code, tt.wantcode) + } + + // confirm the requst to $DocumentRoot/auth is redirected to $DocumentRoot/auth/$state + redirectURL, err := url.Parse(rrA.Header()["Location"][0]) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, fmt.Sprintf("%s/auth/%s/", cfg.Cfg.DocumentRoot, state), redirectURL.Path) + + }) + } +} + +func TestAuthStateHandler(t *testing.T) { + type args struct { + w http.ResponseWriter + r *http.Request + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + AuthStateHandler(tt.args.w, tt.args.r) + }) + } +} diff --git a/handlers/handlers.go b/handlers/handlers.go index d164a066..1a46895e 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -1,519 +1,99 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package handlers import ( - "bytes" - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "html/template" - "io/ioutil" - "mime/multipart" "net/http" - "strings" - - log "github.com/Sirupsen/logrus" - "github.com/bnfinet/lasso/pkg/cfg" - lctx "github.com/bnfinet/lasso/pkg/context" - "github.com/bnfinet/lasso/pkg/cookie" - "github.com/bnfinet/lasso/pkg/domains" - "github.com/bnfinet/lasso/pkg/jwtmanager" - "github.com/bnfinet/lasso/pkg/model" - "github.com/bnfinet/lasso/pkg/structs" "github.com/gorilla/sessions" + "github.com/vouch/vouch-proxy/pkg/providers/discord" + "go.uber.org/zap" "golang.org/x/oauth2" - "golang.org/x/oauth2/google" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/providers/adfs" + "github.com/vouch/vouch-proxy/pkg/providers/alibaba" + "github.com/vouch/vouch-proxy/pkg/providers/azure" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/providers/github" + "github.com/vouch/vouch-proxy/pkg/providers/google" + "github.com/vouch/vouch-proxy/pkg/providers/homeassistant" + "github.com/vouch/vouch-proxy/pkg/providers/indieauth" + "github.com/vouch/vouch-proxy/pkg/providers/nextcloud" + "github.com/vouch/vouch-proxy/pkg/providers/openid" + "github.com/vouch/vouch-proxy/pkg/providers/openstax" + "github.com/vouch/vouch-proxy/pkg/structs" ) -// Index variables passed to index.tmpl -type Index struct { - Msg string - TestURL string +// Provider each Provider must support GetuserInfo +type Provider interface { + Configure() + GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) error } -// AuthError sets the values to return to nginx -type AuthError struct { - Error string - JWT string -} +const ( + base64Bytes = 32 +) var ( - gcred structs.GCredentials - genOauth structs.GenericOauth - oauthclient *oauth2.Config - oauthopts oauth2.AuthCodeOption - - // Templates with functions available to them - indexTemplate = template.Must(template.ParseFiles("./templates/index.tmpl")) - - sessstore = sessions.NewCookieStore([]byte(cfg.Cfg.Session.Name)) + sessstore *sessions.CookieStore + log *zap.SugaredLogger + fastlog *zap.Logger + provider Provider ) -func init() { - log.Debug("init handlers") - - // if grcred exist - err := cfg.UnmarshalKey("oauth.google", &gcred) - if err == nil && gcred.ClientID != "" { - log.Info("configuring google oauth") - oauthclient = &oauth2.Config{ - ClientID: gcred.ClientID, - ClientSecret: gcred.ClientSecret, - // RedirectURL: gcred.RedirectURL, - Scopes: []string{ - // You have to select a scope from - // https://developers.google.com/identity/protocols/googlescopes#google_sign-in - "https://www.googleapis.com/auth/userinfo.email", - }, - Endpoint: google.Endpoint, - } - log.Infof("setting google oauth prefered login domain param 'hd' to %s", gcred.PreferredDomain) - oauthopts = oauth2.SetAuthURLParam("hd", gcred.PreferredDomain) - return - } - err = cfg.UnmarshalKey("oauth.generic", &genOauth) - if err == nil { - log.Info("configuring generic oauth") - oauthclient = &oauth2.Config{ - ClientID: genOauth.ClientID, - ClientSecret: genOauth.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: genOauth.AuthURL, - TokenURL: genOauth.TokenURL, - }, - RedirectURL: genOauth.RedirectURL, - Scopes: genOauth.Scopes, - } - } -} - -func randString() string { - b := make([]byte, 32) - rand.Read(b) - return base64.StdEncoding.EncodeToString(b) -} - -func loginURL(r *http.Request, state string) string { - // State can be some kind of random generated hash string. - // See relevant RFC: http://tools.ietf.org/html/rfc6749#section-10.12 - var url = "" - if gcred.ClientID != "" { - domain := domains.Matches(r.Host) - for i, v := range gcred.RedirectURLs { - log.Debugf("array value at [%d]=%v", i, v) - if strings.Contains(v, domain) { - oauthclient.RedirectURL = v - break - } - } - url = oauthclient.AuthCodeURL(state, oauthopts) - } else { - url = oauthclient.AuthCodeURL(state) - } - - // log.Debugf("loginUrl %s", url) - return url -} - -// FindJWT look for JWT in Cookie, Header and Query String in that order -func FindJWT(r *http.Request) string { - jwt, err := cookie.Cookie(r) - if err != nil { - log.Error(err) - // return "" - } - log.Debugf("jwtCookie from cookie: %s", jwt) - if jwt == "" { - jwt = r.Header.Get(cfg.Cfg.Headers.SSO) - log.Debugf("jwtCookie from header %s: %s", cfg.Cfg.Headers.SSO, jwt) - } - if jwt == "" { - jwt = r.URL.Query().Get(cfg.Cfg.Headers.SSO) - log.Debugf("jwtCookie from querystring %s: %s", cfg.Cfg.Headers.SSO, jwt) - } - return jwt -} - -// ClaimsFromJWT look everywhere for the JWT, then parse the jwt and return the claims -func ClaimsFromJWT(jwt string) (jwtmanager.LassoClaims, error) { - // get jwt from cookie.name - // parse the jwt - var claims jwtmanager.LassoClaims - - jwtParsed, err := jwtmanager.ParseTokenString(jwt) - if err != nil { - // it didn't parse, which means its bad, start over - log.Error("jwtParsed returned error, clearing cookie") - return claims, err - } - - claims, err = jwtmanager.PTokenClaims(jwtParsed) - if err != nil { - // claims = jwtmanager.PTokenClaims(jwtParsed) - // if claims == &jwtmanager.LassoClaims{} { - return claims, err - } - return claims, nil -} - -// the standard error -// this is captured by nginx, which converts the 401 into 302 to the login page -func error401(w http.ResponseWriter, r *http.Request, ae AuthError) { - log.Error(ae.Error) - cookie.ClearCookie(w, r) - context.WithValue(r.Context(), lctx.StatusCode, http.StatusUnauthorized) - // w.Header().Set("X-Lasso-Error", ae.Error) - http.Error(w, ae.Error, http.StatusUnauthorized) - // TODO put this back in place if multiple auth mechanism are available - // c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"message": errStr}) -} - -func error401na(w http.ResponseWriter, r *http.Request) { - error401(w, r, AuthError{Error: "not authorized"}) -} - -// ValidateRequestHandler /validate -// TODO this should use the handler interface -func ValidateRequestHandler(w http.ResponseWriter, r *http.Request) { - log.Debug("/validate") - - jwt := FindJWT(r) - // if jwt != "" { - if jwt == "" { - error401(w, r, AuthError{Error: "no jwt found"}) - return - } - - claims, err := ClaimsFromJWT(jwt) - if err != nil { - // no email in jwt - error401(w, r, AuthError{err.Error(), jwt}) - return - } - if claims.Email == "" { - // no email in jwt - error401(w, r, AuthError{"no email found in jwt", jwt}) - return - } - log.Infof("email from jwt cookie: %s", claims.Email) - - if !jwtmanager.SiteInClaims(r.Host, &claims) { - error401(w, r, AuthError{"not authorized for " + r.Host, jwt}) - return - } - - // renderIndex(w, "user found from email "+user.Email) - w.Header().Add("X-Lasso-User", claims.Email) - log.Debugf("X-Lasso-User response headers %s", w.Header().Get("X-Lasso-User")) - renderIndex(w, "user found in jwt "+claims.Email) - - // TODO - // parse the jwt and see if the claim is valid for the domain - - // update user last access in a go routine - // user := structs.User{} - // err = model.User([]byte(email), &user) - // if err != nil { - // // no email in jwt, or no email in db - // error401(w, r, err.Error()) - // return - // } - // if user.Email == "" { - // error401(w, r, "no email found in db") - // return - // } - - // put the site - go func() { - s := structs.Site{Domain: r.Host} - log.Debugf("site struct: %v", s) - model.PutSite(s) - }() -} - -// LogoutHandler /logout -// currently performs a 302 redirect to Google -func LogoutHandler(w http.ResponseWriter, r *http.Request) { - log.Debug("/logout") - cookie.ClearCookie(w, r) - - log.Debug("saving session") - sessstore.MaxAge(-1) - session, err := sessstore.Get(r, cfg.Cfg.Session.Name) - if err != nil { - log.Error(err) - } - session.Save(r, w) - sessstore.MaxAge(300) - renderIndex(w, "you have been logged out") -} - -// LoginHandler /login -// currently performs a 302 redirect to Google -func LoginHandler(w http.ResponseWriter, r *http.Request) { - log.Debug("/login") - // no matter how you ended up here, make sure the cookie gets cleared out - cookie.ClearCookie(w, r) - - session, err := sessstore.Get(r, cfg.Cfg.Session.Name) - if err != nil { - log.Error(err) - } - - // set the state varialbe in the session - var state = randString() - session.Values["state"] = state - log.Debugf("session state set to %s", session.Values["state"]) - - // increment the failure counter for this domain - - // redirectURL comes from nginx in the query string - var redirectURL = r.URL.Query().Get("url") - if redirectURL != "" { - // TODO store the originally requested URL so we can redirec on the roundtrip - session.Values["requestedURL"] = redirectURL - log.Debugf("session requestedURL set to %s", session.Values["requestedURL"]) - } - - // stop them after three failures for this URL - var failcount = 0 - if session.Values[redirectURL] != nil { - failcount = session.Values[redirectURL].(int) - log.Debugf("failcount for %s is %d", redirectURL, failcount) - } - failcount++ - session.Values[redirectURL] = failcount - - log.Debug("saving session") - session.Save(r, w) - - if failcount > 2 { - var lassoError = r.URL.Query().Get("error") - renderIndex(w, "too many redirects for "+redirectURL+" - "+lassoError) - } else { - // bounce to oauth provider for login - var lURL = loginURL(r, state) - log.Debugf("redirecting to oauthURL %s", lURL) - context.WithValue(r.Context(), lctx.StatusCode, 302) - http.Redirect(w, r, lURL, 302) - } -} - -func renderIndex(w http.ResponseWriter, msg string) { - if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURL: cfg.Cfg.TestURL}); err != nil { - log.Error(err) - } -} - -// VerifyUser validates that the domains match for the user -// func VerifyUser(u structs.User) (ok bool, err error) { -func VerifyUser(u interface{}) (ok bool, err error) { - // (w http.ResponseWriter, req http.Request) - // is Hd google specific? probably yes - // TODO rewrite / abstract this validation - ok = false - - // TODO: how do we manage the user? - user := u.(structs.User) - - if len(cfg.Cfg.Domains) != 0 && !domains.IsUnderManagement(user.Email) { - err = fmt.Errorf("Email %s is not within a lasso managed domain", user.Email) - // } else if !domains.IsUnderManagement(user.HostDomain) { - // err = fmt.Errorf("HostDomain %s is not within a lasso managed domain", u.HostDomain) - } else { - ok = true - } - return ok, err -} - -// CallbackHandler /auth -// - validate info from Google -// - create user -// - issue jwt in the form of a cookie -func CallbackHandler(w http.ResponseWriter, r *http.Request) { - log.Debug("/auth") - // Handle the exchange code to initiate a transport. - - session, err := sessstore.Get(r, cfg.Cfg.Session.Name) - if err != nil { - log.Errorf("could not find session store %s", cfg.Cfg.Session.Name) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // is the nonce "state" valid? - queryState := r.URL.Query().Get("state") - if session.Values["state"] != queryState { - log.Errorf("Invalid session state: stored %s, returned %s", session.Values["state"], queryState) - renderIndex(w, "Invalid session state.") - return - } - - user := structs.User{} - - if err := getUserInfo(r, &user); err != nil { - log.Error(err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - log.Debug(user) - - if ok, err := VerifyUser(user); !ok { - log.Error(err) - renderIndex(w, fmt.Sprintf("User is not authorized. %s Please try agian.", err)) - return - } - - // SUCCESS!! they are authorized - - // store the user in the database - model.PutUser(user) - - // issue the jwt - tokenstring := jwtmanager.CreateUserTokenString(user) - cookie.SetCookie(w, r, tokenstring) - - // get the originally requested URL so we can send them on their way - redirectURL := session.Values["requestedURL"].(string) - if redirectURL != "" { - // clear out the session value - session.Values["requestedURL"] = "" - session.Values[redirectURL] = 0 - session.Save(r, w) - - // and redirect - context.WithValue(r.Context(), lctx.StatusCode, 302) - http.Redirect(w, r, redirectURL, 302) - return - } - // otherwise serve an html page - renderIndex(w, tokenstring) -} - -// TODO: put all getUserInfo logic into its own pkg - -func getUserInfo(r *http.Request, user *structs.User) error { - - // indieauth sends the "me" setting in json back to the callback, so just pluck it from the callback - if genOauth.Provider == "indieauth" { - return getUserInfoFromIndieAuth(r, user) - } - - providerToken, err := oauthclient.Exchange(oauth2.NoContext, r.URL.Query().Get("code")) - if err != nil { - return err - } - // make the "third leg" request back to google to exchange the token for the userinfo - client := oauthclient.Client(oauth2.NoContext, providerToken) - if gcred.ClientID != "" { - return getUserInfoFromGoogle(client, user) - } else if genOauth.Provider == "github" { - return getUserInfoFromGithub(client, user, providerToken) - } - return nil -} - -func getUserInfoFromGoogle(client *http.Client, user *structs.User) error { - userinfo, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") - if err != nil { - // http.Error(w, err.Error(), http.StatusBadRequest) - return err - } - defer userinfo.Body.Close() - data, _ := ioutil.ReadAll(userinfo.Body) - log.Println("google userinfo body: ", string(data)) - if err = json.Unmarshal(data, user); err != nil { - log.Errorln(err) - // renderIndex(w, "Error marshalling response. Please try agian.") - // c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"message": }) - return err - } - return nil -} - -// github -// https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/ -func getUserInfoFromGithub(client *http.Client, user *structs.User, ptoken *oauth2.Token) error { - - log.Errorf("ptoken.AccessToken: %s", ptoken.AccessToken) - userinfo, err := client.Get("https://api.github.com/user?access_token=" + ptoken.AccessToken) - if err != nil { - // http.Error(w, err.Error(), http.StatusBadRequest) - return err - } - defer userinfo.Body.Close() - data, _ := ioutil.ReadAll(userinfo.Body) - log.Println("github userinfo body: ", string(data)) - if err = json.Unmarshal(data, user); err != nil { - log.Errorln(err) - // renderIndex(w, "Error marshalling response. Please try agian.") - // c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"message": }) - return err - } - log.Debug(user) - return nil -} - -// indieauth -// https://indieauth.com/developers -type indieResponse struct { - Email string `json:"me"` -} - -func getUserInfoFromIndieAuth(r *http.Request, user *structs.User) error { - - code := r.URL.Query().Get("code") - log.Errorf("ptoken.AccessToken: %s", code) - var b bytes.Buffer - w := multipart.NewWriter(&b) - // v.Set("code", code) - fw, err := w.CreateFormField("code") - if err != nil { - return err - } - if _, err = fw.Write([]byte(code)); err != nil { - return err - } - // v.Set("redirect_uri", genOauth.RedirectURL) - fw, err = w.CreateFormField("redirect_uri") - if _, err = fw.Write([]byte(genOauth.RedirectURL)); err != nil { - return err - } - // v.Set("client_id", genOauth.ClientID) - fw, err = w.CreateFormField("client_id") - if _, err = fw.Write([]byte(genOauth.ClientID)); err != nil { - return err - } - w.Close() - - req, err := http.NewRequest("POST", genOauth.AuthURL, &b) - if err != nil { - return err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("Accept", "application/json") - - // v := url.Values{} - // userinfo, err := client.PostForm(genOauth.UserInfoURL, v) - - client := &http.Client{} - userinfo, err := client.Do(req) - - if err != nil { - // http.Error(w, err.Error(), http.StatusBadRequest) - return err - } - defer userinfo.Body.Close() - data, _ := ioutil.ReadAll(userinfo.Body) - log.Println("indieauth userinfo body: ", string(data)) - ir := indieResponse{} - if err := json.Unmarshal(data, &ir); err != nil { - log.Errorln(err) - return err +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger + fastlog = cfg.Logging.FastLogger + // http://www.gorillatoolkit.org/pkg/sessions + sessstore = sessions.NewCookieStore([]byte(cfg.Cfg.Session.Key)) + sessstore.Options.HttpOnly = cfg.Cfg.Cookie.HTTPOnly + sessstore.Options.Secure = cfg.Cfg.Cookie.Secure + sessstore.Options.SameSite = cookie.SameSite() + sessstore.Options.MaxAge = cfg.Cfg.Session.MaxAge * 60 // convert minutes to seconds + + provider = getProvider() + provider.Configure() + common.Configure() +} + +func getProvider() Provider { + switch cfg.GenOAuth.Provider { + case cfg.Providers.IndieAuth: + return indieauth.Provider{} + case cfg.Providers.ADFS: + return adfs.Provider{} + case cfg.Providers.Azure: + return azure.Provider{} + case cfg.Providers.HomeAssistant: + return homeassistant.Provider{} + case cfg.Providers.OpenStax: + return openstax.Provider{} + case cfg.Providers.Google: + return google.Provider{} + case cfg.Providers.GitHub: + return github.Provider{PrepareTokensAndClient: common.PrepareTokensAndClient} + case cfg.Providers.Nextcloud: + return nextcloud.Provider{} + case cfg.Providers.OIDC: + return openid.Provider{} + case cfg.Providers.Alibaba: + return alibaba.Provider{} + case cfg.Providers.Discord: + return discord.Provider{} + default: + // shouldn't ever reach this since cfg checks for a properly configure `oauth.provider` + log.Fatal("oauth.provider appears to be misconfigured, please check your config") + return nil } - user.Email = ir.Email - log.Debug(user) - return nil } diff --git a/handlers/handlers_test.go b/handlers/handlers_test.go new file mode 100644 index 00000000..2ec3101e --- /dev/null +++ b/handlers/handlers_test.go @@ -0,0 +1,181 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/domains" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/responses" + "github.com/vouch/vouch-proxy/pkg/structs" +) + +// var ( +// token = &oauth2.Token{AccessToken: "123"} +// ) + +// setUp load config file and then call Configure() for dependent packages +func setUp(configFile string) { + os.Setenv("VOUCH_CONFIG", filepath.Join(os.Getenv("VOUCH_ROOT"), configFile)) + cfg.InitForTestPurposes() + + Configure() + domains.Configure() + jwtmanager.Configure() + cookie.Configure() + responses.Configure() +} + +func TestVerifyUserPositiveUserInWhiteList(t *testing.T) { + setUp("/config/testing/handler_whitelist.yml") + user := &structs.User{Username: "test@example.com", Email: "test@example.com", Name: "Test Name"} + ok, err := verifyUser(*user) + assert.True(t, ok) + assert.Nil(t, err) +} + +func TestVerifyUserPositiveAllowAllUsers(t *testing.T) { + setUp("/config/testing/handler_allowallusers.yml") + + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + + ok, err := verifyUser(*user) + assert.True(t, ok) + assert.Nil(t, err) +} + +func TestVerifyUserPositiveByEmail(t *testing.T) { + setUp("/config/testing/handler_email.yml") + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + ok, err := verifyUser(*user) + assert.True(t, ok) + assert.Nil(t, err) +} + +func TestVerifyUserPositiveByTeam(t *testing.T) { + setUp("/config/testing/handler_teams.yml") + + // cfg.Cfg.TeamWhiteList = append(cfg.Cfg.TeamWhiteList, "org1/team2", "org1/team1") + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + user.TeamMemberships = append(user.TeamMemberships, "org1/team3") + user.TeamMemberships = append(user.TeamMemberships, "org1/team1") + ok, err := verifyUser(*user) + assert.True(t, ok) + assert.Nil(t, err) +} + +func TestVerifyUserNegativeByTeam(t *testing.T) { + setUp("/config/testing/handler_teams.yml") + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + // cfg.Cfg.TeamWhiteList = append(cfg.Cfg.TeamWhiteList, "org1/team1") + + ok, err := verifyUser(*user) + assert.False(t, ok) + assert.NotNil(t, err) +} + +func TestVerifyUserPositiveNoDomainsConfigured(t *testing.T) { + setUp("/config/testing/handler_nodomains.yml") + + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + cfg.Cfg.Domains = make([]string, 0) + ok, err := verifyUser(*user) + + assert.True(t, ok) + assert.Nil(t, err) +} + +func TestVerifyUserNegative(t *testing.T) { + setUp("/config/testing/test_config.yml") + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + ok, err := verifyUser(*user) + + assert.False(t, ok) + assert.NotNil(t, err) +} + +// copied from jwtmanager_test.go +// it should live there but circular imports are resolved if it lives here +var ( + u1 = structs.User{ + Username: "test@testing.com", + Name: "Test Name", + } + t1 = structs.PTokens{ + PAccessToken: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjRvaXU4In0.eyJzdWIiOiJuZnlmZSIsImF1ZCI6ImltX29pY19jbGllbnQiLCJqdGkiOiJUOU4xUklkRkVzUE45enU3ZWw2eng2IiwiaXNzIjoiaHR0cHM6XC9cL3Nzby5tZXljbG91ZC5uZXQ6OTAzMSIsImlhdCI6MTM5MzczNzA3MSwiZXhwIjoxMzkzNzM3MzcxLCJub25jZSI6ImNiYTU2NjY2LTRiMTItNDU2YS04NDA3LTNkMzAyM2ZhMTAwMiIsImF0X2hhc2giOiJrdHFvZVBhc2praVY5b2Z0X3o5NnJBIn0.g1Jc9DohWFfFG3ppWfvW16ib6YBaONC5VMs8J61i5j5QLieY-mBEeVi1D3vr5IFWCfivY4hZcHtoJHgZk1qCumkAMDymsLGX-IGA7yFU8LOjUdR4IlCPlZxZ_vhqr_0gQ9pCFKDkiOv1LVv5x3YgAdhHhpZhxK6rWxojg2RddzvZ9Xi5u2V1UZ0jukwyG2d4PRzDn7WoRNDGwYOEt4qY7lv_NO2TY2eAklP-xYBWu0b9FBElapnstqbZgAXdndNs-Wqp4gyQG5D0owLzxPErR9MnpQfgNcai-PlWI_UrvoopKNbX0ai2zfkuQ-qh6Xn8zgkiaYDHzq4gzwRfwazaqA", + PIdToken: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjRvaXU4In0.eyJzdWIiOiJuZnlmZSIsImF1ZCI6ImltX29pY19jbGllbnQiLCJqdGkiOiJUOU4xUklkRkVzUE45enU3ZWw2eng2IiwiaXNzIjoiaHR0cHM6XC9cL3Nzby5tZXljbG91ZC5uZXQ6OTAzMSIsImlhdCI6MTM5MzczNzA3MSwiZXhwIjoxMzkzNzM3MzcxLCJub25jZSI6ImNiYTU2NjY2LTRiMTItNDU2YS04NDA3LTNkMzAyM2ZhMTAwMiIsImF0X2hhc2giOiJrdHFvZVBhc2praVY5b2Z0X3o5NnJBIn0.g1Jc9DohWFfFG3ppWfvW16ib6YBaONC5VMs8J61i5j5QLieY-mBEeVi1D3vr5IFWCfivY4hZcHtoJHgZk1qCumkAMDymsLGX-IGA7yFU8LOjUdR4IlCPlZxZ_vhqr_0gQ9pCFKDkiOv1LVv5x3YgAdhHhpZhxK6rWxojg2RddzvZ9Xi5u2V1UZ0jukwyG2d4PRzDn7WoRNDGwYOEt4qY7lv_NO2TY2eAklP-xYBWu0b9FBElapnstqbZgAXdndNs-Wqp4gyQG5D0owLzxPErR9MnpQfgNcai-PlWI_UrvoopKNbX0ai2zfkuQ-qh6Xn8zgkiaYDHzq4gzwRfwazaqA", + } + + lc jwtmanager.VouchClaims + + claimjson = `{ + "sub": "f:a95afe53-60ba-4ac6-af15-fab870e72f3d:mrtester", + "groups": ["Website Users", "Test Group"], + "given_name": "Mister", + "family_name": "Tester", + "email": "mrtester@test.int" + }` + customClaims = structs.CustomClaims{} +) + +// copied from jwtmanager_test.go +func init() { + // log.SetLevel(log.DebugLevel) + + lc = jwtmanager.VouchClaims{ + Username: u1.Username, + CustomClaims: customClaims.Claims, + PAccessToken: t1.PAccessToken, + PIdToken: t1.PIdToken, + RegisteredClaims: jwtmanager.RegisteredClaims, + } + json.Unmarshal([]byte(claimjson), &customClaims.Claims) +} + +func TestParsedIdPTokens(t *testing.T) { + tests := []struct { + name string + configFile string + wantIDPTokens bool + }{ + {"no IdP tokens", "/config/testing/handler_claims.yml", false}, + {"wants IdP tokens", "/config/testing/jwtmanager_has_idp_token_claims.yml", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(tt.configFile) + uts, err := jwtmanager.NewVPJWT(u1, customClaims, t1) + assert.NoError(t, err) + utsParsed, _ := jwtmanager.ParseTokenString(uts) + utsPtokens, _ := jwtmanager.PTokenClaims(utsParsed) + + if tt.wantIDPTokens { + if t1.PIdToken != utsPtokens.PIdToken || t1.PAccessToken != utsPtokens.PAccessToken { + t.Errorf("got PIdToken = %s, PAccessToken = %s, \nwant %s , %s", utsPtokens.PIdToken, utsPtokens.PAccessToken, t1.PIdToken, t1.PAccessToken) + } + } else { + if utsPtokens.PIdToken != "" || utsPtokens.PAccessToken != "" { + t.Errorf("PIdToken and PAccessToken = should be '' got '%s', '%s'", utsPtokens.PIdToken, utsPtokens.PAccessToken) + } + } + }) + } + +} diff --git a/handlers/healthcheck.go b/handlers/healthcheck.go new file mode 100644 index 00000000..396568bf --- /dev/null +++ b/handlers/healthcheck.go @@ -0,0 +1,25 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "fmt" + "net/http" +) + +// HealthcheckHandler /healthcheck +// just returns 200 '{ "ok": true }' +func HealthcheckHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintf(w, "{ \"ok\": true }"); err != nil { + log.Error(err) + } +} diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 00000000..34f26f58 --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,315 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/gorilla/sessions" + cv "github.com/nirasan/go-oauth-pkce-code-verifier" + "github.com/theckman/go-securerandom" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/domains" + "github.com/vouch/vouch-proxy/pkg/responses" + "golang.org/x/oauth2" +) + +// see https://github.com/vouch/vouch-proxy/issues/282 +var errTooManyRedirects = errors.New("too many unsuccessful authorization attempts for the requested URL") + +const failCountLimit = 6 + +// LoginHandler /login +// currently performs a 302 redirect to Google +func LoginHandler(w http.ResponseWriter, r *http.Request) { + log.Debug("/login") + // no matter how you ended up here, make sure the cookie gets cleared out + cookie.ClearCookie(w, r) + + session, err := sessstore.Get(r, cfg.Cfg.Session.Name) + if err != nil { + log.Infof("couldn't find existing encrypted secure cookie with name %s: %s (probably fine)", cfg.Cfg.Session.Name, err) + } + + state, err := generateStateNonce() + if err != nil { + log.Error(err) + } + + // set the state variable in the session + session.Values["state"] = state + + // set the path for the session cookie to only send the correct cookie to /auth/{state}/ + // must have a trailing slash. Otherwise, it is send to all endpoints that _start_ with the cookie path. + session.Options.Path = fmt.Sprintf("%s/auth/%s/", cfg.Cfg.DocumentRoot, state) + + log.Debugf("session state set to %s", session.Values["state"]) + + // requestedURL comes from nginx in the query string via a 302 redirect + // it sets the ultimate destination + // https://vouch.yoursite.com/login?url= + // need to clean the URL to prevent malicious redirection + var requestedURL string + if requestedURL, err = getValidRequestedURL(r); err != nil { + responses.Error400(w, r, err) + return + } + + // set session variable for eventual 302 redirecton to original request + session.Values["requestedURL"] = requestedURL + log.Debugf("session requestedURL set to %s", session.Values["requestedURL"]) + + // increment the failure counter for the requestedURL + // stop them after three failures for this URL + var failcount = 0 + if session.Values[requestedURL] != nil { + failcount = session.Values[requestedURL].(int) + log.Debugf("failcount for %s is %d", requestedURL, failcount) + } + failcount++ + session.Values[requestedURL] = failcount + + // Add code challenge if enabled + if cfg.GenOAuth.CodeChallengeMethod != "" { + log.Debugf("Adding code challenge") + appendCodeChallenge(*session) + } + + log.Debugf("saving session with failcount %d", failcount) + if err = session.Save(r, w); err != nil { + log.Error(err) + } + + if failcount > failCountLimit { + var vouchError = r.URL.Query().Get("error") + responses.Error400(w, r, fmt.Errorf("/login %w %s %s", errTooManyRedirects, requestedURL, vouchError)) + return + } + + // SUCCESS + // bounce to oauth provider for login + var oURL = oauthLoginURL(r, *session) + log.Debugf("redirecting to oauthURL %s", oURL) + responses.Redirect302(w, r, oURL) +} + +var ( + errNoURL = errors.New("no destination URL requested") + errInvalidURL = errors.New("requested destination URL appears to be invalid") + errURLNotHTTP = errors.New("requested destination URL is not a valid URL (does not begin with 'http://' or 'https://')") + errDangerQS = errors.New("requested destination URL has a dangerous query string") + badStrings = []string{"http://", "https://", "data:", "ftp://", "ftps://", "//", "javascript:"} + reAmpSemi = regexp.MustCompile("[&;]") +) + +// inspect login query params to located the url param, while taking into account that the login URL may be +// presented in an RFC-non-compliant way (for example, it is common for the url param to +// not have its own query params property encoded, leading to URLs like +// http://host/login?X-Vouch-Token=token&url=http://host/path?param=value¶m2=value2&vouch-failcount=value3 +// where some params -- here X-Vouch-Token and vouch-failcount -- belong to login, and some others +// -- here param and param2 -- belong to the url param of login) +// The algorithm is as follows: +// * All login params starting with vouch- or x-vouch- (case insensitively) are treated as true login params +// * The "error" login param (case sensitively) is treated as true login param +// * The "rd" login param (case sensitively) added by nginx ingress is treated as true login param https://github.com/vouch/vouch-proxy/issues/289 +// * All other login params are treated as non-login params +// * All non-login params between the url param and the first true login param are folded into the url param +// * All remaining non-login params are considered stray non-login params +// +// Returns +// * _, _, err: if an error occurred while parsing the URL +// * URL, empty array, nil: if URL is valid and contains no stray non-login params +// * URL, array of stray params, nil: if URL is valid and contains stray non-login params +func normalizeLoginURLParam(loginURL *url.URL) (*url.URL, []string, error) { + // url.URL.Query return a map and therefore makes no guarantees about param order + // Therefore we have to ascertain the param order by inspecting the raw query + var urlParam *url.URL = nil // Will be url.URL for the url param + urlParamDone := false // Will be true when we're done building urlParam (but we're still checking for stray params) + strays := []string{} // List of stray params + + for _, param := range reAmpSemi.Split(loginURL.RawQuery, -1) { + paramKeyVal := strings.Split(param, "=") + paramKey := paramKeyVal[0] + lcParamKey := strings.ToLower(paramKey) + isVouchParam := strings.HasPrefix(lcParamKey, cfg.Branding.LCName) || + strings.HasPrefix(lcParamKey, "x-"+cfg.Branding.LCName) || + paramKey == "error" || // Used by VouchProxy login + paramKey == "rd" // Passed to VouchProxy by nginx-ingress and then ignored (see #289) + + if urlParam == nil { + // Still looking for url param + if paramKey == "url" { + // Found it + parsed, e := url.ParseQuery(param) + + if e != nil { + return nil, []string{}, e // failure to parse url param + } + + urlParam, e = url.Parse(parsed.Get("url")) + + if e != nil { + return nil, []string{}, e // failure to parse url param + } + } else if !isVouchParam { + // Non-vouch param before url param is a stray param + log.Infof("Stray param in login request (%s)", paramKey) + strays = append(strays, paramKey) + } // else vouch param before url param, doesn't change outcome + } else { + // Looking at params after url param + if !urlParamDone && isVouchParam { + // First vouch param after url param + urlParamDone = true + // But keep going to check for strays + } else if !urlParamDone { + // Non-vouch param after url and before first vouch param, fold it into urlParam + if urlParam.RawQuery == "" { + urlParam.RawQuery = param + } else { + urlParam.RawQuery = urlParam.RawQuery + "&" + param + } + } else if !isVouchParam { + // Non-vouch param after vouch param is a stray param + log.Infof("Stray param in login request (%s)", paramKey) + strays = append(strays, paramKey) + } // else vouch param after vouch param, doesn't change outcome + } + } + + log.Debugf("Login url param normalized to '%s'", urlParam) + return urlParam, strays, nil + +} + +func getValidRequestedURL(r *http.Request) (string, error) { + u, strays, err := normalizeLoginURLParam(r.URL) + + if len(strays) > 0 { + log.Debugf("Stray params in login url (%+q) will be ignored", strays) + } + + if err != nil { + return "", fmt.Errorf("not a valid login URL: %w %s", errInvalidURL, err) + } + + if u == nil || u.String() == "" { + return "", errNoURL + } + + if u.Scheme != "http" && u.Scheme != "https" { + return "", errURLNotHTTP + } + + for _, v := range u.Query() { + // log.Debugf("validateRequestedURL %s:%s", k, v) + for _, vval := range v { + for _, bad := range badStrings { + if strings.HasPrefix(strings.ToLower(vval), bad) { + return "", fmt.Errorf("%w looks bad: %s includes %s", errDangerQS, vval, bad) + } + } + } + } + + hostname := u.Hostname() + if cfg.GenOAuth.Provider != cfg.Providers.IndieAuth { + d := domains.Matches(hostname) + if d == "" { + inCookieDomain := (hostname == cfg.Cfg.Cookie.Domain || strings.HasSuffix(hostname, "."+cfg.Cfg.Cookie.Domain)) + if cfg.Cfg.Cookie.Domain == "" || !inCookieDomain { + return "", fmt.Errorf("%w: not within a %s managed domain", errInvalidURL, cfg.Branding.FullName) + } + } + } + + // if the requested URL is http then the cookie cannot be seen if cfg.Cfg.Cookie.Secure is set + if u.Scheme == "http" && cfg.Cfg.Cookie.Secure { + return "", fmt.Errorf("%w: mismatch between requested destination URL and '%s.cookie.secure: %v' (the cookie is only visible to 'https' but the requested site is 'http')", errInvalidURL, cfg.Branding.LCName, cfg.Cfg.Cookie.Secure) + } + + return u.String(), nil +} + +func oauthLoginURL(r *http.Request, session sessions.Session) string { + // State can be some kind of random generated hash string. + // See relevant RFC: http://tools.ietf.org/html/rfc6749#section-10.12 + var state string = session.Values["state"].(string) + opts := []oauth2.AuthCodeOption{} + if cfg.GenOAuth.Provider == cfg.Providers.IndieAuth { + return cfg.OAuthClient.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type", "id")) + } + + // cfg.OAuthClient.RedirectURL is set in cfg + // this checks the multiple redirect case for multiple matching domains + if len(cfg.GenOAuth.RedirectURLs) > 0 { + found := false + domain := domains.Matches(r.Host) + log.Debugf("/login looking for callback_url matching %s from host %s", domain, r.Host) + for _, v := range cfg.GenOAuth.RedirectURLs { + if strings.Contains(v, domain) { + found = true + log.Debugf("/login callback_url set to %s", v) + cfg.OAuthClient.RedirectURL = v + break + } + } + if !found { + log.Infof("/login no callback_url matched %s (is the `Host` header being passed to Vouch Proxy?)", domain) + } + } + // append code challenge and code challenge method query parameters if enabled + + if cfg.GenOAuth.CodeChallengeMethod != "" { + opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", cfg.GenOAuth.CodeChallengeMethod)) + opts = append(opts, oauth2.SetAuthURLParam("code_challenge", session.Values["codeChallenge"].(string))) + } + if cfg.OAuthopts != nil { + opts = append(opts, cfg.OAuthopts...) + } + return cfg.OAuthClient.AuthCodeURL(state, opts...) +} + +var regExJustAlphaNum, _ = regexp.Compile("[^a-zA-Z0-9]+") + +func generateStateNonce() (string, error) { + state, err := securerandom.URLBase64InBytes(base64Bytes) + if err != nil { + return "", err + } + state = regExJustAlphaNum.ReplaceAllString(state, "") + return state, nil +} + +func appendCodeChallenge(session sessions.Session) { + var codeChallenge string + var CodeVerifier, _ = cv.CreateCodeVerifier() + switch strings.ToUpper(cfg.GenOAuth.CodeChallengeMethod) { + case "S256": + codeChallenge = CodeVerifier.CodeChallengeS256() + case "PLAIN": + // TODO support plain text code challenge + //codeChallenge = CodeVerifier.CodeChallengePlain() + log.Fatal("plain code challenge method is not supported") + return + default: + log.Fatal("Code challenge method %s is invalid", cfg.GenOAuth.CodeChallengeMethod) + return + } + session.Values["codeChallenge"] = codeChallenge + session.Values["codeVerifier"] = CodeVerifier.Value +} diff --git a/handlers/login_test.go b/handlers/login_test.go new file mode 100644 index 00000000..71f519a9 --- /dev/null +++ b/handlers/login_test.go @@ -0,0 +1,261 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func Test_normalizeLoginURL(t *testing.T) { + setUp("/config/testing/handler_login_url.yml") + tests := []struct { + name string + url string + want string + wantStray []string + wantErr bool + }{ + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + {"extra params", "http://host/login?url=http://host/path?p2=2", "http://host/path?p2=2", []string{}, false}, + {"extra params (blank)", "http://host/login?url=http://host/path?p2=", "http://host/path?p2=", []string{}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // Even though the p1 param is not a login param, we do not interpret is as part of the url param because it precedes it + {"prior params", "http://host/login?p1=1&url=http://host/path", "http://host/path", []string{"p1"}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // We assume vouch-* is a login param and do not fold it into url + {"vouch-* params after", "http://host/login?url=http://host/path&vouch-xxx=2", "http://host/path", []string{}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // We assume vouch-* is a login param and do not fold it into url + {"vouch-* params before", "http://host/login?vouch-xxx=1&url=http://host/path", "http://host/path", []string{}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // We assume x-vouch-* is a login param and do not fold it into url + {"x-vouch-* params after", "http://host/login?url=http://host/path&vouch-xxx=2", "http://host/path", []string{}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // We assume x-vouch-* is a login param and do not fold it into url + {"x-vouch-* params before", "http://host/login?x-vouch-xxx=1&url=http://host/path", "http://host/path", []string{}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // Even though p1 is not a login param, we do not interpret is as part of url because it follows a login param (vouch-*) + {"params after vouch-* params", "http://host/login?url=http://host/path&vouch-xxx=2&p3=3", "http://host/path", []string{"p3"}, false}, + // This is not an RFC-compliant URL because it does not encode :// in the url param; we accept it anyway + // Even though p1 is not a login param, we do not interpret is as part of url because it follows a login param (x-vouch-*) + {"params after x-vouch-* params", "http://host/login?url=http://host/path&x-vouch-xxx=2&p3=3", "http://host/path", []string{"p3"}, false}, + // This is not an RFC-compliant URL; it combines all the aspects above + {"all params", "http://host/login?p1=1&url=http://host/path?p2=2&p3=3&x-vouch-xxx=4&vouch=5&error=6&p7=7", "http://host/path?p2=2&p3=3", []string{"p1", "p7"}, false}, + // This is an RFC-compliant URL + {"all params (encoded)", "http://host/login?p1=1&url=http%3a%2f%2fhost/path%3fp2=2%26p3=3&x-vouch-xxx=4&vouch=5&error=6&p7=7", "http://host/path?p2=2&p3=3", []string{"p1", "p7"}, false}, + // This is not an RFC-compliant URL; it combines all the aspects above, and it uses semicolons as parameter separators + // Note that when we fold a stray param into the url param, we always do so with &s + {"all params (semicolons)", "http://host/login?p1=1;url=http://host/path?p2=2;p3=3;x-vouch-xxx=4;p5=5", "http://host/path?p2=2&p3=3", []string{"p1", "p5"}, false}, + // This is an RFC-compliant URL that uses semicolons as parameter separators + {"all params (encoded, semicolons)", "http://host/login?p1=1;url=http%3a%2f%2fhost/path%3fp2=2%3bp3=3;x-vouch-xxx=4;p5=5", "http://host/path?p2=2;p3=3", []string{"p1", "p5"}, false}, + // Real world tests + // since v0.4.0 the vouch README has specified an Nginx config including a 302 redirect in the following format... + {"Vouch Proxy README (with error)", "http://host/login?url=http://host/path?p2=2&vouch-failcount=3&X-Vouch-Token=TOKEN&error=anerror", "http://host/path?p2=2", []string{}, false}, + {"Vouch Proxy README (blank error)", "http://host/login?url=http://host/path?p2=2&vouch-failcount=&X-Vouch-Token=&error=", "http://host/path?p2=2", []string{}, false}, + {"Vouch Proxy README (semicolons, blank error)", "http://host/login?url=http://host/path?p2=2;p3=3&vouch-failcount=&X-Vouch-Token=&error=", "http://host/path?p2=2&p3=3", []string{}, false}, + // Nginx Ingress controler for Kubernetes adds the parameter `rd` to these calls + // https://github.com/vouch/vouch-proxy/issues/289 + {"rd param appended by Nginx Ingress", "http://host/login?url=http://host/path?p2=2&p3=3&vouch-failcount=&X-Vouch-Token=&error=&rd=http%3a%2f%2fhost/path%3fp2=2%3bp3=3", "http://host/path?p2=2&p3=3", []string{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, _ := url.Parse(tt.url) + got, stray, err := normalizeLoginURLParam(u) + if got.String() != tt.want { + t.Errorf("normalizeLoginURLParam() = %v, want %v", got, tt.want) + } + if !cmp.Equal(stray, tt.wantStray) { + t.Errorf("normalizeLoginURLParam() stray params incorrectly parsed, got %+q, expected %+q", stray, tt.wantStray) + } + if (err != nil) != tt.wantErr { + t.Errorf("normalizeLoginURLParam() err = %v", err) + } + }) + } +} + +func Test_getValidRequestedURL(t *testing.T) { + setUp("/config/testing/handler_login_url.yml") + r := &http.Request{} + tests := []struct { + name string + url string + want string + wantErr bool + }{ + {"no https", "example.com/dest", "", true}, + {"redirection chaining", "http://example.com/dest?url=https://", "", true}, + {"redirection chaining upper case", "http://example.com/dest?url=HTTPS://someplaceelse.com", "", true}, + {"redirection chaining no protocol", "http://example.com/dest?url=//someplaceelse.com", "", true}, + {"redirection chaining escaped https://", "http://example.com/dest?url=https%3a%2f%2fsomeplaceelse.com", "", true}, + {"data uri", "http://example.com/dest?url=data:text/plain,Example+Text", "", true}, + {"javascript uri", "http://example.com/dest?url=javascript:alert(1)", "", true}, + {"not in domain but contains domain", "http://example.com.somewherelse.com/", "", true}, + {"not in domain", "http://somewherelse.com/", "", true}, + {"should warn", "https://example.com/", "https://example.com/", false}, + {"should be fine", "http://example.com/", "http://example.com/", false}, + {"multiple query param", "http://example.com/?strange=but-true&also-strange=but-false", "http://example.com/?strange=but-true&also-strange=but-false", false}, + {"multiple query params, one of them bad", "http://example.com/?strange=but-true&also-strange=but-false&strange-but-bad=https://badandstrange.com", "", true}, + {"multiple query params, one of them bad (escaped)", "http://example.com/?strange=but-true&also-strange=but-false&strange-but-bad=https%3a%2f%2fbadandstrange.com", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r.URL, _ = url.Parse("http://vouch.example.com/login?url=" + tt.url) + got, err := getValidRequestedURL(r) + if (err != nil) != tt.wantErr { + t.Errorf("getValidRequestedURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("getValidRequestedURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoginHandlerDocumentRoot(t *testing.T) { + handler := http.HandlerFunc(LoginHandler) + + tests := []struct { + name string + configFile string + wantcode int + }{ + {"general test", "/config/testing/handler_login_url_document_root.yml", http.StatusFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(tt.configFile) + + req, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/logout?url=http://myapp.example.com/login", nil) + req.Header.Set("Host", "my.example.com") + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode) + } + + found := false + for _, c := range rr.Result().Cookies() { + if c.Name == cfg.Cfg.Session.Name { + if strings.HasPrefix(c.Path, cfg.Cfg.DocumentRoot+"/auth") { + found = true + } + } + } + if !found { + t.Errorf("session cookie is not set into path that begins with Cfg.DocumentRoot %s", cfg.Cfg.DocumentRoot) + } + + // confirm the OAuthClient has a properly configured + redirectURL, err := url.Parse(rr.Header()["Location"][0]) + if err != nil { + t.Fatal(err) + } + redirectParam := redirectURL.Query().Get("redirect_uri") + assert.NotEmpty(t, cfg.OAuthClient.RedirectURL, "cfg.OAuthClient.RedirectURL is empty") + assert.NotEmpty(t, redirectParam, "redirect_uri should not be empty when redirected to google oauth") + + }) + } +} +func TestLoginHandler(t *testing.T) { + handler := http.HandlerFunc(LoginHandler) + + tests := []struct { + name string + configFile string + wantcode int + }{ + {"general test", "/config/testing/handler_login_url.yml", http.StatusFound}, + {"general test", "/config/testing/handler_login_redirecturls.yml", http.StatusFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(tt.configFile) + + req, err := http.NewRequest("GET", "/logout?url=http://myapp.example.com/login", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode) + } + + // confirm the OAuthClient has a properly configured + redirectURL, err := url.Parse(rr.Header()["Location"][0]) + if err != nil { + t.Fatal(err) + } + redirectParam := redirectURL.Query().Get("redirect_uri") + assert.NotEmpty(t, cfg.OAuthClient.RedirectURL, "cfg.OAuthClient.RedirectURL is empty") + assert.NotEmpty(t, redirectParam, "redirect_uri should not be empty when redirected to google oauth") + + }) + } +} +func TestLoginErrTooManyRedirects(t *testing.T) { + + handler := http.HandlerFunc(LoginHandler) + + setUp("/config/testing/handler_login_url.yml") + + tests := []struct { + name string + wantcode int + numRequests int + }{ + {"try the URL a few times", http.StatusFound, failCountLimit}, // after we make successive number of requests up to the failCountLimit `` + {"then fail ErrTooManyRedirects", http.StatusBadRequest, 1}, // then we generate the error and return `400 Bad Request` + } + + var rr *httptest.ResponseRecorder + req, err := http.NewRequest("GET", "/logout?url=http://myapp.example.com/login", nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + for i := 0; i < tt.numRequests; i++ { + if err != nil { + t.Fatal(err) + } + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode) + } + + for _, c := range rr.Result().Cookies() { + req.AddCookie(c) + } + + } + + }) + } +} diff --git a/handlers/logout.go b/handlers/logout.go new file mode 100644 index 00000000..46a920dc --- /dev/null +++ b/handlers/logout.go @@ -0,0 +1,99 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/responses" +) + +var errUnauthRedirURL = fmt.Errorf("/logout The requested url is not present in `%s.post_logout_redirect_uris`", cfg.Branding.LCName) + +// LogoutHandler /logout +// Destroys Vouch session +// If oauth.end_session_endpoint present in conf, also redirects to destroy session at oauth provider +// If "url" param present in request, also redirects to that (after destroying one or both sessions) +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + log.Debug("/logout") + + jwt := jwtmanager.FindJWT(r) + claims, err := jwtmanager.ClaimsFromJWT(jwt) + if err != nil { + log.Error(err) + } + + var token = "" + if claims != nil { + token = claims.PIdToken + } + + cookie.ClearCookie(w, r) + log.Debug("/logout deleting session") + session, err := sessstore.Get(r, cfg.Cfg.Session.Name) + session.Options.MaxAge = -1 + if err != nil { + log.Error(err) + } + if err = session.Save(r, w); err != nil { + log.Error(err) + } + + providerLogoutURL := cfg.GenOAuth.LogoutURL + redirectURL := r.URL.Query().Get("url") + + // Make sure that redirectURL, if given, is allowed by config + if redirectURL != "" { + redirectValid := false + for _, allowed := range cfg.Cfg.LogoutRedirectURLs { + if allowed == redirectURL { + log.Debugf("/logout found ") + redirectValid = true + break + } + } + if !redirectValid { + responses.Error400(w, r, fmt.Errorf("%w: %s", errUnauthRedirURL, redirectURL)) + return + } + } + + // If provider logout URL is configured, redirect to it (and pass redirectURL along) + // If provider logout URL is not configured, redirect directly to redirectURL + if providerLogoutURL != "" { + newRedirectURL, err := url.Parse(providerLogoutURL) + if err != nil { + log.Error(err) + } + + q := newRedirectURL.Query() + if redirectURL != "" { + q.Add("post_logout_redirect_uri", redirectURL) + } + if token != "" { + // Optional in spec, required by some providers (Okta, for example) + q.Add("id_token_hint", token) + } + newRedirectURL.RawQuery = q.Encode() + redirectURL = newRedirectURL.String() + } + + if redirectURL != "" { + responses.Redirect302(w, r, redirectURL) + } else { + responses.RenderIndex(w, "/logout you have been logged out") + } +} diff --git a/handlers/logout_test.go b/handlers/logout_test.go new file mode 100644 index 00000000..d5b273f5 --- /dev/null +++ b/handlers/logout_test.go @@ -0,0 +1,96 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func TestLogoutHandler(t *testing.T) { + setUp("/config/testing/handler_logout_url.yml") + handler := http.HandlerFunc(LogoutHandler) + + tests := []struct { + name string + url string + wantcode int + }{ + {"allowed", "http://myapp.example.com/login", http.StatusFound}, + {"allowed", "https://oauth2.googleapis.com/revoke", http.StatusFound}, + {"not allowed", "http://myapp.example.com/loginagain", http.StatusBadRequest}, + {"not allowed", "http://google.com/", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/logout?url="+tt.url, nil) + req.Host = "myapp.example.com" + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode) + } + if rr.Code == http.StatusFound && rr.Header().Get("Location") != tt.url { + t.Errorf("LogoutHandler() redirect = %s, want %s", rr.Header().Get("Location"), tt.url) + } + }) + } +} + +func TestProviderLogoutHandler(t *testing.T) { + setUp("/config/testing/handler_logout_provider.yml") + handler := http.HandlerFunc(LogoutHandler) + + tests := []struct { + name string + url string + wantcode int + }{ + {"allowed", "http://myapp.example.com/login", http.StatusFound}, + {"allowed", "https://oauth2.googleapis.com/revoke", http.StatusFound}, + {"not allowed", "http://myapp.example.com/loginagain", http.StatusBadRequest}, + {"not allowed", "http://google.com/", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/logout?url="+tt.url, nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode) + } + if rr.Code == http.StatusFound { + wanted := tt.url + req, _ := http.NewRequest("GET", cfg.GenOAuth.LogoutURL, nil) + + q := req.URL.Query() + q.Add("post_logout_redirect_uri", wanted) + req.URL.RawQuery = q.Encode() + wanted = req.URL.String() + + if rr.Header().Get("Location") != wanted { + t.Errorf("LogoutHandler() redirect = %s, want %s", rr.Header().Get("Location"), wanted) + } + } + }) + } +} diff --git a/handlers/validate.go b/handlers/validate.go new file mode 100644 index 00000000..8d225dd8 --- /dev/null +++ b/handlers/validate.go @@ -0,0 +1,132 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "strings" + + "go.uber.org/zap" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/domains" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/responses" +) + +var ( + errNoJWT = errors.New("no jwt found in request") + errNoUser = errors.New("no User found in jwt") +) + +// ValidateRequestHandler /validate +func ValidateRequestHandler(w http.ResponseWriter, r *http.Request) { + fastlog.Debug("/validate") + + jwt := jwtmanager.FindJWT(r) + if jwt == "" { + send401or200PublicAccess(w, r, errNoJWT) + return + } + + claims, err := jwtmanager.ClaimsFromJWT(jwt) + if err != nil { + send401or200PublicAccess(w, r, err) + return + } + + if claims.Username == "" { + send401or200PublicAccess(w, r, errNoUser) + return + } + + if !cfg.Cfg.AllowAllUsers { + if !claims.SiteInAudience(r.Host) || domains.Matches(r.Host) == "" { + send401or200PublicAccess(w, r, + fmt.Errorf("http header 'Host: %s' not authorized for configured `vouch.domains` (is Host being sent properly?)", r.Host)) + return + } + } + + generateCustomClaimsHeaders(w, claims) + w.Header().Add(cfg.Cfg.Headers.User, claims.Username) + w.Header().Add(cfg.Cfg.Headers.Success, "true") + + if cfg.Cfg.Headers.AccessToken != "" && claims.PAccessToken != "" { + w.Header().Add(cfg.Cfg.Headers.AccessToken, claims.PAccessToken) + } + if cfg.Cfg.Headers.IDToken != "" && claims.PIdToken != "" { + w.Header().Add(cfg.Cfg.Headers.IDToken, claims.PIdToken) + } + // fastlog.Debugf("response headers %+v", w.Header()) + // fastlog.Debug("response header", + // zap.String(cfg.Cfg.Headers.User, w.Header().Get(cfg.Cfg.Headers.User))) + fastlog.Debug("response header", + zap.Any("all headers", w.Header())) + + // good to go!! + + if cfg.Cfg.Testing { + responses.RenderIndex(w, "user authorized "+claims.Username) + } else { + responses.OK200(w, r) + } + +} + +func generateCustomClaimsHeaders(w http.ResponseWriter, claims *jwtmanager.VouchClaims) { + if len(cfg.Cfg.Headers.ClaimsCleaned) > 0 { + log.Debug("Found claims in config, finding specific keys...") + // Run through all the claims found + for k, v := range claims.CustomClaims { + // Run through the claims we are looking for + for claim, header := range cfg.Cfg.Headers.ClaimsCleaned { + // Check for matching claim + if claim == k { + log.Debugf("Found matching claim key: %s", k) + if val, ok := v.([]interface{}); ok { + strs := make([]string, len(val)) + for i, v := range val { + strs[i] = fmt.Sprintf("\"%s\"", v) + } + log.Debugf("Adding header for claim %s - %s: %s", k, header, val) + w.Header().Add(header, strings.Join(strs, ",")) + } else { + // convert to string + val := fmt.Sprint(v) + if reflect.TypeOf(val).Kind() == reflect.String { + // if val, ok := v.(string); ok { + w.Header().Add(header, val) + log.Debugf("Adding header for claim %s - %s: %s", k, header, val) + } else { + log.Errorf("Couldn't parse header type for %s %+v. Please submit an issue.", k, v) + } + } + } + } + } + } + +} + +func send401or200PublicAccess(w http.ResponseWriter, r *http.Request, e error) { + if cfg.Cfg.PublicAccess { + log.Debugf("error: %s, but public access is '%v', returning OK200", e, cfg.Cfg.PublicAccess) + w.Header().Add(cfg.Cfg.Headers.User, "") + responses.OK200(w, r) + return + } + + responses.Error401(w, r, e) +} diff --git a/handlers/validate_test.go b/handlers/validate_test.go new file mode 100644 index 00000000..b1364749 --- /dev/null +++ b/handlers/validate_test.go @@ -0,0 +1,282 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + vegeta "github.com/tsenart/vegeta/lib" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/structs" +) + +func BenchmarkValidateRequestHandler(b *testing.B) { + setUp("/config/testing/handler_email.yml") + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + tokens := structs.PTokens{} + customClaims := structs.CustomClaims{} + + userTokenString, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(b, err) + + c := &http.Cookie{ + // Name: cfg.Cfg.Cookie.Name + "_1of1", + Name: cfg.Cfg.Cookie.Name, + Value: userTokenString, + Expires: time.Now().Add(1 * time.Hour), + } + + handler := jwtmanager.JWTCacheHandler(http.HandlerFunc(ValidateRequestHandler)) + // handler := http.HandlerFunc(ValidateRequestHandler) + ts := httptest.NewServer(handler) + defer ts.Close() + + req, err := http.NewRequest("GET", "/validate", nil) + if err != nil { + b.Fatal(err) + } + req.Host = "myapp.example.com" + req.AddCookie(c) + w := httptest.NewRecorder() + + for i := 0; i < b.N; i++ { + handler.ServeHTTP(w, req) + } + +} + +func TestValidateRequestHandlerPerf(t *testing.T) { + if _, ok := os.LookupEnv("ISTRAVIS"); ok { + t.Skip("travis doesn't like perf tests, skipping") + } + if _, ok := os.LookupEnv("SKIPPERFTEST"); ok { + t.Skip("skipping performance tests") + } + + setUp("/config/testing/handler_email.yml") + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + tokens := structs.PTokens{} + customClaims := structs.CustomClaims{} + + vpjwt, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(t, err) + + c := &http.Cookie{ + // Name: cfg.Cfg.Cookie.Name + "_1of1", + Name: cfg.Cfg.Cookie.Name, + Value: vpjwt, + Expires: time.Now().Add(1 * time.Hour), + } + + // handler := http.HandlerFunc(ValidateRequestHandler) + handler := jwtmanager.JWTCacheHandler(http.HandlerFunc(ValidateRequestHandler)) + ts := httptest.NewServer(handler) + defer ts.Close() + + freq := 1000 + duration := 5 * time.Second + + rate := vegeta.Rate{Freq: freq, Per: time.Second} + h := &http.Header{} + h.Add("Cookie", c.String()) + h.Add("Host", "myapp.example.com") + targeter := vegeta.NewStaticTargeter(vegeta.Target{ + Method: "GET", + URL: ts.URL, + Header: *h, + }) + + attacker := vegeta.NewAttacker() + + var metrics vegeta.Metrics + mustFail := false + for res := range attacker.Attack(targeter, rate, duration, "Big Bang!") { + if res.Code != http.StatusOK { + t.Logf("/validate perf %d response code %d", res.Seq, res.Code) + mustFail = true + } + metrics.Add(res) + } + metrics.Close() + + limit := time.Millisecond + if mustFail || metrics.Latencies.P95 > limit { + t.Logf("99th percentile latencies: %s", metrics.Latencies.P99) + t.Logf("95th percentile latencies: %s", metrics.Latencies.P95) + t.Logf("50th percentile latencies: %s", metrics.Latencies.P50) + t.Logf("mean latencies: %s", metrics.Latencies.Mean) + t.Logf("max latencies: %s", metrics.Latencies.Max) + t.Logf("/validate 95th percentile latency is higher than %s", limit) + if mustFail { + t.Logf("not all requests were %d", http.StatusOK) + } + t.FailNow() + } + +} + +func TestValidateRequestHandlerWithGroupClaims(t *testing.T) { + setUp("/config/testing/handler_claims.yml") + + customClaims := structs.CustomClaims{ + Claims: map[string]interface{}{ + "sub": "f:a95afe53-60ba-4ac6-af15-fab870e72f3d:mrtester", + "groups": []string{ + "Website Users", + "Test Group", + }, + "given_name": "Mister", + "family_name": "Tester", + "email": "mrtester@test.int", + "boolean_claim": true, + // Auth0 custom claim are URLs + // https://auth0.com/docs/tokens/guides/create-namespaced-custom-claims + "http://www.example.com/favorite_color": "blue", + }, + } + + groupHeader := "X-Vouch-IdP-Claims-Groups" + booleanHeader := "X-Vouch-IdP-Claims-Boolean-Claim" + familyNameHeader := "X-Vouch-IdP-Claims-Family-Name" + favoriteColorHeader := "X-Vouch-IdP-Claims-Www-Example-Com-Favorite-Color" + + tokens := structs.PTokens{} + + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + vpjwt, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(t, err) + + req, err := http.NewRequest("GET", "/validate", nil) + if err != nil { + t.Fatal(err) + } + + req.AddCookie(&http.Cookie{ + // Name: cfg.Cfg.Cookie.Name + "_1of1", + Name: cfg.Cfg.Cookie.Name, + Value: vpjwt, + Expires: time.Now().Add(1 * time.Hour), + }) + + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(ValidateRequestHandler) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + // Check that the custom claim headers are what we expected + customClaimHeaders := map[string][]string{ + strings.ToLower(groupHeader): {}, + strings.ToLower(booleanHeader): {}, + strings.ToLower(familyNameHeader): {}, + strings.ToLower(favoriteColorHeader): {}, + } + + for k, v := range rr.Result().Header { + k = strings.ToLower(k) + if _, exist := customClaimHeaders[k]; exist { + customClaimHeaders[k] = v + } + } + expectedCustomClaimHeaders := map[string][]string{ + strings.ToLower(groupHeader): {"\"Website Users\",\"Test Group\""}, + strings.ToLower(booleanHeader): {"true"}, + strings.ToLower(familyNameHeader): {"Tester"}, + strings.ToLower(favoriteColorHeader): {"blue"}, + } + assert.Equal(t, expectedCustomClaimHeaders, customClaimHeaders) +} + +func TestJWTCacheHandler(t *testing.T) { + setUp("/config/testing/handler_logout_url.yml") + handler := jwtmanager.JWTCacheHandler(http.HandlerFunc(ValidateRequestHandler)) + + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + tokens := structs.PTokens{} + customClaims := structs.CustomClaims{} + + jwt, err := jwtmanager.NewVPJWT(*user, customClaims, tokens) + assert.NoError(t, err) + badjwt := strings.ReplaceAll(jwt, "a", "z") + badjwt = strings.ReplaceAll(badjwt, "b", "x") + + c := &http.Cookie{ + // Name: cfg.Cfg.Cookie.Name + "_1of1", + Name: cfg.Cfg.Cookie.Name, + Value: jwt, + Expires: time.Now().Add(1 * time.Hour), + Domain: cfg.Cfg.Cookie.Domain, + } + + cBlank := &http.Cookie{ + // Name: cfg.Cfg.Cookie.Name + "_1of1", + Name: cfg.Cfg.Cookie.Name, + Value: "", + Expires: time.Now().Add(1 * time.Hour), + Domain: cfg.Cfg.Cookie.Domain, + } + + tests := []struct { + name string + cookie *http.Cookie + bearerJWT string + wantcode int + }{ + // because we're testing the cacheing we run these multiple times + {"authorized 1", c, "", http.StatusOK}, + {"authorized 2", c, "", http.StatusOK}, + {"notauthorized 1", cBlank, "", http.StatusUnauthorized}, + {"notauthorized 2", cBlank, "", http.StatusUnauthorized}, + {"authorized 3", c, "", http.StatusOK}, + {"bearer 1", nil, jwt, http.StatusOK}, + {"badBearer 1", nil, badjwt, http.StatusUnauthorized}, + // {"badBearer", nil, badjwt, http.StatusUnauthorized}, + {"bearer 2", nil, jwt, http.StatusOK}, + {"badBearer 2", nil, badjwt, http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/validate", nil) + req.Host = "myapp.example.com" + + if tt.cookie != nil { + req.AddCookie(tt.cookie) + } + + // https://github.com/vouch/vouch-proxy/issues/278 + if tt.bearerJWT != "" { + req.Header.Add("Authorization", "Bearer "+tt.bearerJWT) + } + + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != tt.wantcode { + t.Errorf("JWTCacheHandler() = %v, want %v", rr.Code, tt.wantcode) + } + }) + } +} diff --git a/lasso_flow.dot b/lasso_flow.dot deleted file mode 100644 index 50984fa4..00000000 --- a/lasso_flow.dot +++ /dev/null @@ -1,41 +0,0 @@ -# graphviz diagram - -digraph Lasso { - - compound=true; - ratio=fill; node[fontsize=24]; - splines=line; - - browse_to_private_site -> nginx_receive_request; - nginx_receive_request -> validate; - validate -> evaluate_jwt; - evaluate_jwt -> NOT_AUTH; - NOT_AUTH -> redirect_to_login; - redirect_to_login -> redirected_to_login; - redirected_to_login -> login; - login -> redirect_to_google_oauth; - redirect_to_google_oauth -> redirected_to_google_oauth - redirected_to_google_oauth -> google_oauth; - google_oauth -> redirect_to_authorize; - redirect_to_authorize -> authorize; - authorize -> confirm_state; - confirm_state -> state_confirmed; - state_confirmed -> redirect_to_original_url; - redirect_to_original_url -> browse_to_private_site; - - evaluate_jwt -> SUCCESS; - SUCCESS -> set_cookie; - set_cookie -> homepage; - - subgraph cluster_bob { label="Bob"; browse_to_private_site; set_cookie; redirected_to_login; redirected_to_google_oauth} - subgraph cluster_nginx { label="nginx"; nginx_receive_request; NOT_AUTH; SUCCESS; redirect_to_login;} - subgraph cluster_lasso { label="lasso - login.oursites.com"; validate; evaluate_jwt; login; redirect_to_google_oauth; authorize; state_confirmed;} - subgraph cluster_google { label="Google Login"; google_oauth; redirect_to_authorize; confirm_state;} - subgraph cluster_oursite { label="private.oursites.com"; homepage } - - { rank = same; browse_to_private_site; nginx_receive_request; validate; } - { rank = same; evaluate_jwt; NOT_AUTH; redirect_to_login; redirected_to_login;} - { rank = same; login; redirect_to_google_oauth; redirected_to_google_oauth; google_oauth;} - { rank = same; SUCCESS; homepage;} - -} \ No newline at end of file diff --git a/lasso_flow.png b/lasso_flow.png deleted file mode 100644 index fa3ee99d..00000000 Binary files a/lasso_flow.png and /dev/null differ diff --git a/main.go b/main.go index 5cc3b906..7f237595 100644 --- a/main.go +++ b/main.go @@ -1,60 +1,292 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package main -// lasso -// github.com/bnfinet/lasso +// Vouch Proxy +// github.com/vouch/vouch-proxy + +/* + +Hello Developer! Thanks for looking at the code! + +Before submitting PRs, please see the README... +https://github.com/vouch/vouch-proxy#submitting-a-pull-request-for-a-new-feature + +*/ import ( + "embed" + "errors" + "flag" + "fmt" + "io/fs" + "log" + "net" "net/http" + "os" + "os/user" "strconv" + "strings" "time" - log "github.com/Sirupsen/logrus" + // "net/http/pprof" - "github.com/bnfinet/lasso/handlers" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/timelog" - tran "github.com/bnfinet/lasso/pkg/transciever" + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" + + "github.com/vouch/vouch-proxy/handlers" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/domains" + "github.com/vouch/vouch-proxy/pkg/healthcheck" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/responses" + "github.com/vouch/vouch-proxy/pkg/timelog" +) + +// `version`, `semver` and others are populated during build by.. +// go build -i -v -ldflags="-X main.version=$(git describe --always --long) -X main.semver=v$(git semver get)" +var ( + version = "undefined" + builddt = "undefined" + host = "undefined" + semver = "undefined" + branch = "undefined" + uname = "undefined" + logger *zap.SugaredLogger + fastlog *zap.Logger + showVersion = flag.Bool("version", false, "display version and exit") + help = flag.Bool("help", false, "show usage") + scheme = map[bool]string{ + false: "http", + true: "https", + } + // doProfile = flag.Bool("profile", false, "run profiler at /debug/pprof") ) +//go:embed static +var staticFs embed.FS + +//go:embed templates +var templatesFs embed.FS + +//go:embed .defaults.yml +var defaultsFs embed.FS + +// fwdToZapWriter allows us to use the zap.Logger as our http.Server ErrorLog +// see https://stackoverflow.com/questions/52294334/net-http-set-custom-logger +type fwdToZapWriter struct { + logger *zap.Logger +} + +func (fw *fwdToZapWriter) Write(p []byte) (n int, err error) { + fw.logger.Error(string(p)) + return len(p), nil +} + +// configure() is essentially init() +// for most other projects you would think of this as init() +// this epic issue related to the flag.parse change of behavior for go 1.13 explains some of what's going on here +// https://github.com/golang/go/issues/31859 +// essentially, flag.parse() must be called in vouch-proxy's main() and *not* in init() +// this has a cascading effect on the zap logger since the log level can be set on the command line +// configure() explicitly calls package configure functions (domains.Configure() etc) mostly to set the logger +// without this setup testing and logging are screwed up +func configure() { + flag.Parse() + + if *help { + flag.PrintDefaults() + os.Exit(1) + } + + if *showVersion { + fmt.Printf("%s\n", semver) + os.Exit(0) + } + + cfg.Templates = templatesFs + cfg.Defaults = defaultsFs + + cfg.Configure() + healthcheck.CheckAndExitIfIsHealthCheck() + + logger = cfg.Logging.Logger + fastlog = cfg.Logging.FastLogger + + if err := cfg.ValidateConfiguration(); err != nil { + logger.Fatal(err) + } + + domains.Configure() + jwtmanager.Configure() + cookie.Configure() + responses.Configure() + handlers.Configure() + timelog.Configure() +} + func main() { - log.Info("starting lasso") - mux := http.NewServeMux() + configure() + listenStr := cfg.Cfg.Listen + if !strings.HasPrefix(cfg.Cfg.Listen, "unix:") { + listenStr = cfg.Cfg.Listen + ":" + strconv.Itoa(cfg.Cfg.Port) + checkTCPPortAvailable(listenStr) + } + + tls := (cfg.Cfg.TLS.Cert != "" && cfg.Cfg.TLS.Key != "") + logger.Infow("starting "+cfg.Branding.FullName, + // "semver": semver, + "version", version, + "buildtime", builddt, + "uname", uname, + "buildhost", host, + "branch", branch, + "semver", semver, + "listen", scheme[tls]+"://"+listenStr, + "tls", tls, + "document_root", cfg.Cfg.DocumentRoot, + "oauth.provider", cfg.GenOAuth.Provider) + // router := mux.NewRouter() - // router.HandleFunc("/", handlers.IndexHandler) + router := httprouter.New() + + if cfg.Cfg.DocumentRoot != "" { + logger.Debugf("adjusting all served URIs to be under %s", cfg.Cfg.DocumentRoot) + } authH := http.HandlerFunc(handlers.ValidateRequestHandler) - mux.HandleFunc("/validate", timelog.TimeLog(authH)) - // mux.HandleFunc("/validate", handlers.ValidateRequestHandler) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/validate", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH))) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/_external-auth-:id", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH))) loginH := http.HandlerFunc(handlers.LoginHandler) - mux.HandleFunc("/login", timelog.TimeLog(loginH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/login", timelog.TimeLog(loginH)) logoutH := http.HandlerFunc(handlers.LogoutHandler) - mux.HandleFunc("/logout", timelog.TimeLog(logoutH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/logout", timelog.TimeLog(logoutH)) callH := http.HandlerFunc(handlers.CallbackHandler) - mux.HandleFunc("/auth", timelog.TimeLog(callH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/auth", timelog.TimeLog(callH)) - // router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) - mux.Handle("/static", http.FileServer(http.Dir("./static"))) + authStateH := http.HandlerFunc(handlers.AuthStateHandler) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/auth/:state/", timelog.TimeLog(authStateH)) - mux.Handle("/ws", tran.WS) + healthH := http.HandlerFunc(handlers.HealthcheckHandler) + router.HandlerFunc(http.MethodGet, "/healthcheck", timelog.TimeLog(healthH)) - // socketio := tran.NewServer() - // mux.Handle("/socket.io/", cors.AllowAll(socketio)) - // http.Handle("/socket.io/", tran.Server) + // this is the documented implemenation for static file serving but it doesn't seem to work with go:embed + // router.ServeFiles("/static/*filepath", http.FS(staticFs)) - var listen = cfg.Cfg.Listen + ":" + strconv.Itoa(cfg.Cfg.Port) - log.Infof("running lasso on %s", listen) + // so instead we publish all three routes + router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/css/main.css", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs)))) + router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/img/favicon.ico", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs)))) + router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/img/multicolor_V_500x500.png", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs)))) + + // this also works for static files + // router.NotFound = http.FileServer(http.FS(staticFs)) + + // + // if *doProfile { + // addProfilingHandlers(router) + // } srv := &http.Server{ - Handler: mux, - Addr: listen, + Handler: router, // Good practice: enforce timeouts for servers you create! - WriteTimeout: 15 * time.Second, - ReadTimeout: 15 * time.Second, + WriteTimeout: time.Duration(cfg.Cfg.WriteTimeout) * time.Second, + ReadTimeout: time.Duration(cfg.Cfg.ReadTimeout) * time.Second, + IdleTimeout: time.Duration(cfg.Cfg.IdleTimeout) * time.Second, + ErrorLog: log.New(&fwdToZapWriter{fastlog}, "", 0), + } + + lis, cleanupFn, err := listen() + if err != nil { + logger.Fatal(err) + } + defer cleanupFn() + + if tls { + srv.TLSConfig = cfg.TLSConfig(cfg.Cfg.TLS.Profile) + logger.Fatal(srv.ServeTLS(lis, cfg.Cfg.TLS.Cert, cfg.Cfg.TLS.Key)) + } else { + logger.Fatal(srv.Serve(lis)) + } + +} + +func listen() (lis net.Listener, cleanupFn func(), err error) { + if !strings.HasPrefix(cfg.Cfg.Listen, "unix:") { + lis, err = net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Cfg.Listen, cfg.Cfg.Port)) + return lis, func() {}, err + } + + socketPath := strings.TrimPrefix(cfg.Cfg.Listen, "unix:") + _, err = os.Stat(socketPath) + if err == nil { + if err = os.Remove(socketPath); err != nil { + return nil, nil, fmt.Errorf("remove existing socket file %s: %w", socketPath, err) + } + } else if !os.IsNotExist(err) { + return nil, nil, fmt.Errorf("stat socket file %s: %w", socketPath, err) + } + + lis, err = net.Listen("unix", socketPath) + if err != nil { + return nil, nil, fmt.Errorf("listen %s: %w", socketPath, err) } - log.Fatal(srv.ListenAndServe()) + mode := fs.FileMode(cfg.Cfg.SocketMode) // defaults to 0660 - see .defaults.yml + if err = os.Chmod(socketPath, mode); err != nil { + return nil, nil, fmt.Errorf("chmod socket file %s %#o", socketPath, mode) + } + if cfg.Cfg.SocketGroup != "" { + group, err := user.LookupGroup(cfg.Cfg.SocketGroup) + if err != nil { + return nil, nil, fmt.Errorf("lookup socket group: %s %w", cfg.Cfg.SocketGroup, err) + } + gid, err := strconv.Atoi(group.Gid) + if err != nil { + return nil, nil, fmt.Errorf("lookup socket group: invalid gid: %w", err) + } + if err := os.Chown(socketPath, -1, gid); err != nil { + return nil, nil, fmt.Errorf("chown socket: group: %s %w", socketPath, err) + } + } + return lis, func() { _ = os.Remove(socketPath) }, nil } + +func checkTCPPortAvailable(listen string) { + logger.Debug("checking availability of tcp port: " + listen) + conn, err := net.Listen("tcp", listen) + if err != nil { + logger.Error(err) + logger.Fatal(errors.New(listen + " is not available (is " + cfg.Branding.FullName + " already running?)")) + } + if err = conn.Close(); err != nil { + logger.Error(err) + } +} + +// if you'd like to enable profiling uncomment these +// func addProfilingHandlers(router *httprouter.Router) { +// // https://stackoverflow.com/questions/47452471/pprof-profile-with-julienschmidtrouter-and-benchmarks-not-profiling-handler +// logger.Debugf("profiling routes added at http://%s:%d/debug/pprof/", cfg.Cfg.Listen, cfg.Cfg.Port) +// router.HandlerFunc(http.MethodGet, "/debug/pprof/", pprof.Index) +// router.HandlerFunc(http.MethodGet, "/debug/pprof/cmdline", pprof.Cmdline) +// router.HandlerFunc(http.MethodGet, "/debug/pprof/profile", pprof.Profile) +// router.HandlerFunc(http.MethodGet, "/debug/pprof/symbol", pprof.Symbol) +// router.HandlerFunc(http.MethodGet, "/debug/pprof/trace", pprof.Trace) +// router.Handler(http.MethodGet, "/debug/pprof/goroutine", pprof.Handler("goroutine")) +// router.Handler(http.MethodGet, "/debug/pprof/heap", pprof.Handler("heap")) +// router.Handler(http.MethodGet, "/debug/pprof/threadcreate", pprof.Handler("threadcreate")) +// router.Handler(http.MethodGet, "/debug/pprof/block", pprof.Handler("block")) +// } diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..176e8b01 --- /dev/null +++ b/main_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func Test_listenUds(t *testing.T) { + setUp(t, "testing/socket_basic.yml") + defer cleanUp() + tempDir, err := os.MkdirTemp("", "") + assert.NoError(t, err) + defer func() { + _ = os.RemoveAll(tempDir) + }() + socketPath := filepath.Join(tempDir, "socket0") + + cfg.Cfg.Listen = strings.Join([]string{"unix", socketPath}, ":") + lis, cleanupFn, err := listen() + assert.NoError(t, err) + assertSocket(t, socketPath) + + fi, err := os.Stat(socketPath) + assert.NoError(t, err) + assert.Equal(t, fs.FileMode(0660), fi.Mode().Perm()) + + assert.NotNil(t, lis) + assert.NoError(t, lis.Close()) + cleanupFn() + _, err = os.Stat(socketPath) + assert.True(t, os.IsNotExist(err)) +} + +// check that socket listening works when the socket path already exists +func Test_listenUds_alreadyExists(t *testing.T) { + setUp(t, "testing/socket_basic.yml") + defer cleanUp() + tempDir, err := os.MkdirTemp("", "") + assert.NoError(t, err) + defer func() { + _ = os.RemoveAll(tempDir) + }() + socketPath := filepath.Join(tempDir, "socket0") + assert.NoError(t, os.WriteFile(socketPath, []byte("stuff in the socket file"), 0600)) + + cfg.Cfg.Listen = strings.Join([]string{"unix", socketPath}, ":") + lis, cleanupFn, err := listen() + assert.NoError(t, err) + assertSocket(t, socketPath) + + assert.NotNil(t, lis) + assert.NoError(t, lis.Close()) + cleanupFn() +} + +// check that the socket mode is adjusted when the SocketMode configuration is present +func Test_listenUds_mode(t *testing.T) { + setUp(t, "config/testing/socket_mode.yml") + defer cleanUp() + tempDir, err := os.MkdirTemp("", "") + assert.NoError(t, err) + defer func() { + _ = os.RemoveAll(tempDir) + }() + socketPath := filepath.Join(tempDir, "socket0") + cfg.Cfg.Listen = strings.Join([]string{"unix", socketPath}, ":") + + lis, cleanupFn, err := listen() + assert.NoError(t, err) + assert.NotNil(t, lis) + assertSocket(t, socketPath) + + stat, err := os.Stat(socketPath) + assert.NoError(t, err) + assert.Equal(t, fs.FileMode(cfg.Cfg.SocketMode), stat.Mode().Perm()) + + assert.NoError(t, lis.Close()) + cleanupFn() +} + +func assertSocket(t *testing.T, socketPath string) { + fi, err := os.Stat(socketPath) + assert.NoError(t, err) + assert.Equal(t, os.ModeSocket, fi.Mode()&os.ModeSocket) +} + +func setUp(t *testing.T, configFile string) { + assert.NoError(t, os.Setenv(cfg.Branding.UCName+"_CONFIG", configFile)) + cfg.InitForTestPurposes() +} + +func cleanUp() { + os.Clearenv() +} diff --git a/pkg/capturewriter/capturewriter.go b/pkg/capturewriter/capturewriter.go new file mode 100644 index 00000000..5133d05b --- /dev/null +++ b/pkg/capturewriter/capturewriter.go @@ -0,0 +1,63 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package capturewriter + +import ( + "net/http" + "strconv" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "go.uber.org/zap" +) + +// we wrap ResponseWriter so that we can store the StatusCode +// and then pull it out later for logging +// https://play.golang.org/p/wPHaX9DH-Ik + +// var logger *zap.SugaredLogger +var log *zap.Logger + +// Configure see main.go configure() +func Configure() { + // logger = cfg.Logging.Logger + log = cfg.Logging.FastLogger +} + +// CaptureWriter extends http.ResponseWriter +type CaptureWriter struct { + http.ResponseWriter + StatusCode int +} + +func (w *CaptureWriter) Write(b []byte) (int, error) { + if w.StatusCode == 0 { + w.StatusCode = 200 + // log.Debug("CaptureWriter.Write set w.StatusCode " + strconv.Itoa(w.StatusCode)) + } + return w.ResponseWriter.Write(b) +} + +// Header calls http.Writer.Header() +func (w *CaptureWriter) Header() http.Header { + return w.ResponseWriter.Header() +} + +// WriteHeader calls http.Writer.WriteHeader(code) +func (w *CaptureWriter) WriteHeader(code int) { + w.StatusCode = code + log.Debug("CaptureWriter.Write set w.StatusCode " + strconv.Itoa(w.StatusCode)) + w.ResponseWriter.WriteHeader(code) +} + +// GetStatusCode return w.StatusCode +func (w *CaptureWriter) GetStatusCode() int { + return w.StatusCode +} diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go index 2c2e4a23..b299455d 100644 --- a/pkg/cfg/cfg.go +++ b/pkg/cfg/cfg.go @@ -1,81 +1,393 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package cfg import ( + "bytes" + "embed" + "errors" "flag" + "fmt" + "io" + "io/fs" + "net/http" "os" + "os/user" + "path" + "path/filepath" + "reflect" + "strings" - log "github.com/Sirupsen/logrus" - + "github.com/go-viper/mapstructure/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/kelseyhightower/envconfig" "github.com/spf13/viper" + securerandom "github.com/theckman/go-securerandom" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) -// CfgT lasso jwt cookie configuration -type CfgT struct { - LogLevel string `mapstructure:"logLevel"` - Listen string `mapstructure:"listen"` - Port int `mapstructure:"port"` - Domains []string `mapstructure:"domains"` - JWT struct { - MaxAge int `mapstructure:"maxAge"` - Issuer string `mapstructure:"issuer"` - Secret string `mapstructure:"secret"` - Compress bool `mapstructure:"compress"` +// Config vouch jwt cookie configuration +// Note to developers! Any new config elements +// should use `snake_case` such as `post_logout_redirect_uris` +// in certain situations you'll need to add both a `mapstructure` tag used by viper +// as well as a `envconfig` tag used by https://github.com/kelseyhightower/envconfig +// though most of the time envconfig will use the struct key's name: VOUCH_PORT VOUCH_JWT_MAXAGE +// default values should be set in .defaults.yml +type Config struct { + LogLevel string `mapstructure:"logLevel"` + Listen string `mapstructure:"listen"` + Port int `mapstructure:"port"` + SocketMode int `mapstructure:"socket_mode"` + SocketGroup string `mapstructure:"socket_group"` + DocumentRoot string `mapstructure:"document_root" envconfig:"document_root"` + WriteTimeout int `mapstructure:"writeTimeout"` + ReadTimeout int `mapstructure:"readTimeout"` + IdleTimeout int `mapstructure:"idleTimeout"` + Domains []string `mapstructure:"domains"` + WhiteList []string `mapstructure:"whitelist"` + TeamWhiteList []string `mapstructure:"teamWhitelist"` + AllowAllUsers bool `mapstructure:"allowAllUsers"` + PublicAccess bool `mapstructure:"publicAccess"` + TLS struct { + Cert string `mapstructure:"cert"` + Key string `mapstructure:"key"` + Profile string `mapstructure:"profile"` + } + JWT struct { + SigningMethod string `mapstructure:"signing_method"` + MaxAge int `mapstructure:"maxAge"` // in minutes + Issuer string `mapstructure:"issuer"` + Secret string `mapstructure:"secret"` + PrivateKeyFile string `mapstructure:"private_key_file"` + PublicKeyFile string `mapstructure:"public_key_file"` + Compress bool `mapstructure:"compress"` } Cookie struct { Name string `mapstructure:"name"` + Domain string `mapstructure:"domain"` Secure bool `mapstructure:"secure"` HTTPOnly bool `mapstructure:"httpOnly"` + MaxAge int `mapstructure:"maxage"` + SameSite string `mapstructure:"sameSite"` } + Headers struct { - SSO string `mapstructure:"sso"` - Redirect string `mapstructure:"redirect"` - } - DB struct { - File string `mapstructure:"file"` + JWT string `mapstructure:"jwt"` + User string `mapstructure:"user"` + QueryString string `mapstructure:"querystring"` + Redirect string `mapstructure:"redirect"` + Success string `mapstructure:"success"` + Error string `mapstructure:"error"` + ClaimHeader string `mapstructure:"claimheader"` + Claims []string `mapstructure:"claims"` + AccessToken string `mapstructure:"accesstoken"` + IDToken string `mapstructure:"idtoken"` + ClaimsCleaned map[string]string // the rawClaim is mapped to the actual claims header } Session struct { - Name string `mapstructure:"name"` + Name string `mapstructure:"name"` + MaxAge int `mapstructure:"maxage"` + Key string `mapstructure:"key"` } - TestURL string `mapstructure:"test_url"` + TestURL string `mapstructure:"test_url"` + TestURLs []string `mapstructure:"test_urls"` + Testing bool `mapstructure:"testing"` + LogoutRedirectURLs []string `mapstructure:"post_logout_redirect_uris" envconfig:"post_logout_redirect_uris"` +} + +type branding struct { + LCName string // lower case vouch + UCName string // UPPER CASE VOUCH + CcName string // camelCase Vouch + FullName string // Vouch Proxy + URL string // https://github.com/vouch/vouch-proxy } -// Cfg the main exported config variable -var Cfg CfgT +var ( + // Branding that's our name + Branding = branding{"vouch", "VOUCH", "Vouch", "Vouch Proxy", "https://github.com/vouch/vouch-proxy"} -// V viper object -// var V viper + // RootDir is where Vouch Proxy looks for ./config/config.yml and ./data + RootDir string -func init() { - ParseConfig() - var ll = flag.String("loglevel", Cfg.LogLevel, "enable debug log output") - flag.Parse() - if *ll == "debug" { - log.SetLevel(log.DebugLevel) - log.Debug("logLevel set to debug") + secretFile string + + // CmdLine command line arguments + CmdLine = &cmdLineFlags{ + IsHealthCheck: flag.Bool("healthcheck", false, "invoke healthcheck (check process return value)"), + port: flag.Int("port", -1, "port"), + configFile: flag.String("config", "", "specify alternate config.yml file as command line arg"), + // https://github.com/uber-go/zap/blob/master/flag.go + logLevel: zap.LevelFlag("loglevel", cmdLineLoggingDefault, "set log level to one of: panic, error, warn, info, debug"), + logTest: flag.Bool("logtest", false, "print a series of log messages and exit (used for testing)"), } - log.Debug(viper.AllSettings()) + + // Cfg the main exported config variable + Cfg = &Config{} + // IsHealthCheck see main.go + IsHealthCheck = false + + errConfigNotFound = errors.New("configuration file not found") + // TODO: audit errors and use errConfigIsBad + // errConfigIsBad = errors.New("configuration file is malformed") + + // Templates are loaded from the file system with a go:embed directive in main.go + Templates fs.FS + + // Defaults are loaded from the file system with a go:embed directive in main.go + Defaults embed.FS +) + +type cmdLineFlags struct { + IsHealthCheck *bool + port *int + configFile *string + logLevel *zapcore.Level + logTest *bool } -// ParseConfig parse the config file -func ParseConfig() { - log.Info("opening config") - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(os.Getenv("LASSO_ROOT") + "config") +const ( + // for a Base64 string we need 44 characters to get 32bytes (6 bits per char) + minBase64Length = 44 + base64Bytes = 32 + + // ErrCtxKey set or check the http request context to see if it has errored + // see `responses.Error401` and `jwtmanager.JWTCacheHandler` for example + ErrCtxKey ctxKey = 0 +) + +// use a typed ctxKey to avoid context key collision +// https://blog.golang.org/context#TOC_3.2. +type ctxKey int + +// Configure called at the very top of main() +// the order of config follows the Viper conventions... +// +// The priority of the sources is the following: +// 1. command line flags +// 2. env. variables +// 3. config file +// 4. defaults +// +// so we process these in backwards order (defaults then config file) +func Configure() { + logger.Info("Copyright 2020-2023 the " + Branding.FullName + " Authors") + logger.Warn(Branding.FullName + " is free software with ABSOLUTELY NO WARRANTY.") + + Logging.configureFromCmdline() + + setRootDir() + secretFile = filepath.Join(RootDir, "config/secret") + + // bail if we're testing + if flag.Lookup("test.v") != nil { + log.Debug("`go test` detected, not loading regular config") + Logging.setLogLevel(zap.WarnLevel) + return + } + + setDefaults() + configFileErr := parseConfigFile() + + didConfigFromEnv := configureFromEnv() + + if !didConfigFromEnv && configFileErr != nil { + // then it's probably config file not found + logSysInfo() + log.Fatal(configFileErr) + } + + fixConfigOptions() + Logging.configure() + + if err := configureOauth(); err == nil { + setProviderDefaults() + } + if err := cleanClaimsHeaders(); err != nil { + log.Fatalf("%w: %w", configFileErr, err) + } + if *CmdLine.port != -1 { + Cfg.Port = *CmdLine.port + } + logConfigIfDebug() +} + +// using envconfig +// https://github.com/kelseyhightower/envconfig +func configureFromEnv() bool { + preEnvConfig := *Cfg + err := envconfig.Process(Branding.UCName, Cfg) + if err != nil { + log.Fatal(err.Error()) + } + preEnvGenOAuth := *GenOAuth + + err = envconfig.Process("OAUTH", GenOAuth) + if err != nil { + log.Fatal(err.Error()) + } + // did anything change? + if !reflect.DeepEqual(preEnvConfig, *Cfg) || + !reflect.DeepEqual(preEnvGenOAuth, *GenOAuth) { + + // set logLevel before calling Log.Debugf() + if preEnvConfig.LogLevel != Cfg.LogLevel { + Logging.setLogLevelString(Cfg.LogLevel) + } + // log.Debugf("preEnvConfig %+v", preEnvConfig) + log.Infof("%s configuration set from Environmental Variables", Branding.FullName) + return true + } + return false +} + +// ValidateConfiguration confirm the Configuration is valid +func ValidateConfiguration() error { + if Cfg.Testing { + // Logging.setLogLevel(zap.DebugLevel) + Logging.setDevelopmentLogger() + } + + return basicTest() +} + +func setRootDir() { + // set RootDir from VOUCH_ROOT env var, or to the executable's directory + if os.Getenv(Branding.UCName+"_ROOT") != "" { + RootDir = os.Getenv(Branding.UCName + "_ROOT") + log.Warnf("set cfg.RootDir from VOUCH_ROOT env var: %s", RootDir) + } else { + ex, errEx := os.Executable() + if errEx != nil { + log.Panic(errEx) + } + RootDir = filepath.Dir(ex) + } +} + +// parseConfig parse the config file +func parseConfigFile() error { + configEnv := os.Getenv(Branding.UCName + "_CONFIG") + + if configEnv != "" { + log.Warnf("config file loaded from environmental variable %s: %s", Branding.UCName+"_CONFIG", configEnv) + configFile, _ := filepath.Abs(configEnv) + viper.SetConfigFile(configFile) + } else if *CmdLine.configFile != "" { + log.Infof("config file set on commandline: %s", *CmdLine.configFile) + viper.AddConfigPath("/") + viper.AddConfigPath(RootDir) + viper.AddConfigPath(filepath.Join(RootDir, "config")) + viper.SetConfigFile(*CmdLine.configFile) + } else { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(filepath.Join(RootDir, "config")) + } err := viper.ReadInConfig() // Find and read the config file if err != nil { // Handle errors reading the config file - log.Fatalf("Fatal error config file: %s", err.Error()) - panic(err) + + return fmt.Errorf("%w: %s", errConfigNotFound, err) + } + + if err = checkConfigFileWellFormed(); err != nil { + log.Error("configuration error: config file should have only two top level elements: `vouch` and `oauth`. These and other syntax errors follow...") + log.Error(err) + log.Error("continuing... (maybe you know what you're doing :)") + } + + if err = UnmarshalKey(Branding.LCName, &Cfg); err != nil { + log.Error(err) + } + // don't log the secret! + // log.Debugf("secret: %s", string(Cfg.JWT.Secret)) + return nil +} + +// consolidate config related Log.Debugf() calls so that they can be placed *after* we set the logLevel +func logConfigIfDebug() { + log.Debugf("cfg.RootDir: %s", RootDir) + // log.Debugf("viper settings %+v", viper.AllSettings()) + + // Mask sensitive configuration items before logging + maskedCfg := *Cfg + if len(Cfg.Session.Key) != 0 { + maskedCfg.Session.Key = "XXXXXXXX" + } + if len(Cfg.JWT.Secret) != 0 { + maskedCfg.JWT.Secret = "XXXXXXXX" } - UnmarshalKey("lasso", &Cfg) - // nested defaults is currently *broken* - // https://github.com/spf13/viper/issues/309 - // viper.SetDefault("listen", "0.0.0.0") - // viper.SetDefault(Cfg.Port, 9090) - // viper.SetDefault("Headers.SSO", "X-Lasso-Token") - // viper.SetDefault("Headers.Redirect", "X-Lasso-Requested-URI") - // viper.SetDefault("Cookie.Name", "Lasso") - log.Debugf("secret: %s", string(Cfg.JWT.Secret)) + log.Debugf("Cfg %+v", maskedCfg) + + maskedGenOAuth := *GenOAuth + maskedGenOAuth.ClientID = "12345678" + maskedGenOAuth.ClientSecret = "XXXXXXXX" + log.Debugf("cfg.GenOauth %+v", maskedGenOAuth) +} + +func fixConfigOptions() { + if Cfg.Cookie.MaxAge > Cfg.JWT.MaxAge { + log.Warnf("setting `%s.cookie.maxage` to `%s.jwt.maxage` value of %d minutes (curently set to %d minutes)", Branding.LCName, Branding.LCName, Cfg.JWT.MaxAge, Cfg.Cookie.MaxAge) + Cfg.Cookie.MaxAge = Cfg.JWT.MaxAge + } + + // headers defaults + if !viper.IsSet(Branding.LCName + ".headers.redirect") { + Cfg.Headers.Redirect = "X-" + Branding.CcName + "-Requested-URI" + } + + // jwt defaults + if strings.HasPrefix(Cfg.JWT.SigningMethod, "HS") && len(Cfg.JWT.Secret) == 0 { + Cfg.JWT.Secret = getOrGenerateJWTSecret() + } + + if len(Cfg.JWT.PrivateKeyFile) > 0 && !path.IsAbs(Cfg.JWT.PrivateKeyFile) { + Cfg.JWT.PrivateKeyFile = path.Join(RootDir, Cfg.JWT.PrivateKeyFile) + } + + if len(Cfg.JWT.PublicKeyFile) > 0 && !path.IsAbs(Cfg.JWT.PublicKeyFile) { + Cfg.JWT.PublicKeyFile = path.Join(RootDir, Cfg.JWT.PublicKeyFile) + } + + if len(Cfg.Session.Key) == 0 { + log.Warn("generating random session.key") + rstr, err := securerandom.Base64OfBytes(base64Bytes) + if err != nil { + log.Fatal(err) + } + Cfg.Session.Key = rstr + } + + if Cfg.TestURL != "" { + Cfg.TestURLs = append(Cfg.TestURLs, Cfg.TestURL) + } + +} + +// use viper and mapstructure check to see if +// https://pkg.go.dev/github.com/spf13/viper@v1.20.1?tab=doc#Unmarshal +// https://github.com/go-viper/mapstructure +func checkConfigFileWellFormed() error { + opt := func(dc *mapstructure.DecoderConfig) { + dc.ErrorUnused = true + } + + type quick struct { + Vouch Config + OAuth oauthConfig + } + q := &quick{} + + return viper.Unmarshal(q, opt) } // UnmarshalKey populate struct from contents of cfg tree at key @@ -87,3 +399,333 @@ func UnmarshalKey(key string, rawVal interface{}) error { func Get(key string) string { return viper.GetString(key) } + +// basicTest just a quick sanity check to see if the config is sound +func basicTest() error { + // check oauth config + if err := oauthBasicTest(); err != nil { + return err + } + + if GenOAuth.Provider == "" { + return errors.New("configuration error: required configuration option 'oauth.provider' is not set") + } + if GenOAuth.ClientID == "" { + return errors.New("configuration error: required configuration option 'oauth.client_id' is not set") + } + + // Domains is required _unless_ Cfg.AllowAllUsers is set + if (!Cfg.AllowAllUsers && len(Cfg.Domains) == 0) || + (Cfg.AllowAllUsers && len(Cfg.Domains) > 0) { + return fmt.Errorf("configuration error: either one of %s or %s needs to be set (but not both)", Branding.LCName+".domains", Branding.LCName+".allowAllUsers") + } + + // issue a warning if the secret is too small + log.Debugf("vouch.jwt.secret is %d characters long", len(Cfg.JWT.Secret)) + + allowedSigningMethods := map[string]struct{}{ + "HS256": {}, "HS384": {}, "HS512": {}, // HMAC + "RS256": {}, "RS384": {}, "RS512": {}, // RSA + "ES256": {}, "ES384": {}, "ES512": {}, // ECDSA + } + if _, ok := allowedSigningMethods[Cfg.JWT.SigningMethod]; !ok { + return fmt.Errorf("configuration error: %s.jwt.signing_method value not allowed", Branding.LCName) + } + + if strings.HasPrefix(Cfg.JWT.SigningMethod, "HS") { + if len(Cfg.JWT.PublicKeyFile) > 0 { + return fmt.Errorf("%s.jwt.public_key_file should not be set when using signing method %s", Branding.LCName, Cfg.JWT.SigningMethod) + } + + if len(Cfg.JWT.PrivateKeyFile) > 0 { + return fmt.Errorf("%s.jwt.private_key_file should not be set when using signing method %s", Branding.LCName, Cfg.JWT.SigningMethod) + } + + if len(Cfg.JWT.Secret) < minBase64Length { + log.Errorf("Your secret is too short! (%d characters long). Please consider deleting %s to automatically generate a secret of %d characters", + len(Cfg.JWT.Secret), + Branding.LCName+".jwt.secret", + minBase64Length) + } + } + + if strings.HasPrefix(Cfg.JWT.SigningMethod, "RS") || strings.HasPrefix(Cfg.JWT.SigningMethod, "ES") { + if len(Cfg.JWT.Secret) > 0 { + return fmt.Errorf("%s.jwt.secret should not be set when using signing method %s", Branding.LCName, Cfg.JWT.SigningMethod) + } + + if len(Cfg.JWT.PublicKeyFile) == 0 { + return fmt.Errorf("%s.jwt.public_key_file needs to be set for signing method %s", Branding.LCName, Cfg.JWT.SigningMethod) + } + + if len(Cfg.JWT.PrivateKeyFile) == 0 { + return fmt.Errorf("%s.jwt.private_key_file needs to be set for signing method %s", Branding.LCName, Cfg.JWT.SigningMethod) + } + } + + log.Debugf("vouch.session.key is %d characters long", len(Cfg.Session.Key)) + if len(Cfg.Session.Key) < minBase64Length { + log.Errorf("Your session key is too short! (%d characters long). Please consider deleting %s to automatically generate a secret of %d characters", + len(Cfg.Session.Key), + Branding.LCName+".session.key", + minBase64Length) + } + if Cfg.Cookie.MaxAge < 0 { + return fmt.Errorf("configuration error: cookie maxAge cannot be lower than 0 (currently: %d)", Cfg.Cookie.MaxAge) + } + if Cfg.JWT.MaxAge <= 0 { + return fmt.Errorf("configuration error: JWT maxAge cannot be zero or lower (currently: %d)", Cfg.JWT.MaxAge) + } + if Cfg.Cookie.MaxAge > Cfg.JWT.MaxAge { + return fmt.Errorf("configuration error: Cookie maxAge (%d) cannot be larger than the JWT maxAge (%d)", Cfg.Cookie.MaxAge, Cfg.JWT.MaxAge) + } + + // check tls config + if Cfg.TLS.Key != "" && Cfg.TLS.Cert == "" { + return fmt.Errorf("configuration error: TLS certificate file not provided but TLS key is set (%s)", Cfg.TLS.Key) + } + if Cfg.TLS.Cert != "" && Cfg.TLS.Key == "" { + return fmt.Errorf("configuration error: TLS key file not provided but TLS certificate is set (%s)", Cfg.TLS.Cert) + } + + return nil +} + +// setDefaults set default options for most items from `.defaults.yml` in the root dir +func setDefaults() { + + // viper.SetConfigName(".defaults") + viper.SetConfigType("yaml") + // viper.AddConfigPath(RootDir) + // viper.ReadInConfig() + d, err := Defaults.ReadFile(".defaults.yml") + if err != nil { + log.Fatal(err) + } + viper.ReadConfig(bytes.NewBuffer(d)) + if err := viper.UnmarshalKey(Branding.LCName, &Cfg); err != nil { + log.Error(err) + } + // keep this here for development, we're still pre configurating of LogLevel + // log.Debugf("setDefaults from .defaults.yml %+v", Cfg) + + // bare minimum for healthcheck achieved + if *CmdLine.IsHealthCheck { + return + } + +} + +func claimToHeader(claim string) (string, error) { + was := claim + + // Auth0 allows "namespaceing" of claims and represents them as URLs + claim = strings.TrimPrefix(claim, "http://") + claim = strings.TrimPrefix(claim, "https://") + + // not allowed in header: "(),/:;<=>?@[\]{}" + // https://greenbytes.de/tech/webdav/rfc7230.html#rfc.section.3.2.6 + // and we don't allow underscores `_` or periods `.` because nginx doesn't like them + // "Valid names are composed of English letters, digits, hyphens, and possibly underscores" + // as per http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers + for _, r := range `"(),/\:;<=>?@[]{}_.` { + claim = strings.ReplaceAll(claim, string(r), "-") + } + + // The field-name must be composed of printable ASCII characters (i.e., characters) + // that have values between 33. and 126., decimal, except colon). + // https://github.com/vouch/vouch-proxy/issues/183#issuecomment-564427548 + // get the rune (char) for each claim character + for _, r := range claim { + // log.Debugf("claimToHeader rune %c - %d", r, r) + if r < 33 || r > 126 { + log.Debugf("%s.header.claims %s includes character %c, replacing with '-'", Branding.CcName, was, r) + claim = strings.Replace(claim, string(r), "-", 1) + } + } + claim = Cfg.Headers.ClaimHeader + http.CanonicalHeaderKey(claim) + if claim != was { + log.Infof("%s.header.claims %s will be forwarded downstream in the Header %s", Branding.CcName, was, claim) + log.Debugf("nginx will populate the variable $auth_resp_%s", strings.ReplaceAll(strings.ToLower(claim), "-", "_")) + } + // log.Errorf("%s.header.claims %s will be forwarded in the Header %s", Branding.CcName, was, claim) + return claim, nil + +} + +// fix the claims headers +// https://github.com/vouch/vouch-proxy/issues/183 + +func cleanClaimsHeaders() error { + cleanedHeaders := make(map[string]string) + for _, claim := range Cfg.Headers.Claims { + header, err := claimToHeader(claim) + if err != nil { + return err + } + cleanedHeaders[claim] = header + } + Cfg.Headers.ClaimsCleaned = cleanedHeaders + return nil +} + +// InitForTestPurposes is called by most *_testing.go files in Vouch Proxy +func InitForTestPurposes() { + InitForTestPurposesWithProvider("") +} + +// InitForTestPurposesWithProvider just for testing +func InitForTestPurposesWithProvider(provider string) { + Cfg = &Config{} // clear it out since we're called multiple times from subsequent tests + + Logging.setLogLevel(zapcore.InfoLevel) + setRootDir() + // _, b, _, _ := runtime.Caller(0) + // basepath := filepath.Dir(b) + configEnv := os.Getenv(Branding.UCName + "_CONFIG") + if configEnv == "" { + if err := os.Setenv(Branding.UCName+"_CONFIG", filepath.Join(RootDir, "config/testing/test_config.yml")); err != nil { + log.Error(err) + } + } + // Configure() + // setRootDir() + + // can't use setDefaults for testing which is go:embed based so we do it the old way + // setDefaults() + viper.SetConfigName(".defaults") + viper.SetConfigType("yaml") + viper.AddConfigPath(RootDir) + viper.ReadInConfig() + if err := UnmarshalKey(Branding.LCName, &Cfg); err != nil { + log.Error(err) + } + + // this also mimics the go:embed testing setup + Templates = os.DirFS(RootDir) + + if err := parseConfigFile(); err != nil { + log.Error(err) + } + configureFromEnv() + if err := configureOauth(); err == nil { + setProviderDefaults() + } + fixConfigOptions() + // setDevelopmentLogger() + + // Needed to override the provider, which is otherwise set via yml + if provider != "" { + GenOAuth.Provider = provider + setProviderDefaults() + } + _ = cleanClaimsHeaders() + +} + +func DecryptionKey() (interface{}, error) { + if strings.HasPrefix(Cfg.JWT.SigningMethod, "HS") { + return []byte(Cfg.JWT.Secret), nil + } + + f, err := os.Open(Cfg.JWT.PublicKeyFile) + if err != nil { + return nil, fmt.Errorf("error opening Key %s: %s", Cfg.JWT.PublicKeyFile, err) + } + + keyBytes, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("error reading Key: %s", err) + } + + var key interface{} + switch { + case strings.HasPrefix(Cfg.JWT.SigningMethod, "RS"): + key, err = jwt.ParseRSAPublicKeyFromPEM(keyBytes) + case strings.HasPrefix(Cfg.JWT.SigningMethod, "ES"): + key, err = jwt.ParseECPublicKeyFromPEM(keyBytes) + default: + // signingMethod should already have been validated, this should not happen + return nil, fmt.Errorf("unexpected signing method %s", Cfg.JWT.SigningMethod) + } + + if err != nil { + return nil, fmt.Errorf("error parsing Key: %s", err) + } + + return key, nil +} + +func SigningKey() (interface{}, error) { + if strings.HasPrefix(Cfg.JWT.SigningMethod, "HS") { + return []byte(Cfg.JWT.Secret), nil + } + + f, err := os.Open(Cfg.JWT.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("error opening RSA Key %s: %s", Cfg.JWT.PrivateKeyFile, err) + } + + keyBytes, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("error reading Key: %s", err) + } + + var key interface{} + switch { + case strings.HasPrefix(Cfg.JWT.SigningMethod, "RS"): + key, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + case strings.HasPrefix(Cfg.JWT.SigningMethod, "ES"): + key, err = jwt.ParseECPrivateKeyFromPEM(keyBytes) + default: + // We should have validated this before + return nil, fmt.Errorf("unexpected signing method %s", Cfg.JWT.SigningMethod) + } + + if err != nil { + return nil, fmt.Errorf("error parsing Key: %s", err) + } + + return key, nil +} + +// Check that we have read permission for this file +// https://stackoverflow.com/questions/60128401/how-to-check-if-a-file-is-executable-in-go +func canRead(file string) bool { + stat, err := os.Stat(file) + if err != nil { + log.Debug(err) + return false + } + + m := stat.Mode() + return m&0400 != 0 +} + +// detect if we're in a docker environment +func isDocker() bool { + return canRead("/.dockerenv") +} + +func logSysInfo() { + if isDocker() { + log.Warn("detected Docker environment, beware of Docker userid and permissions changes in v0.36.0") + } + u, err := user.Current() + if err != nil { + log.Error(err) + } + g, err := user.LookupGroupId(u.Gid) + if err != nil { + log.Error(err) + } + p, err := os.FindProcess(os.Getpid()) + if err != nil { + log.Error(err) + } + exe, err := os.Executable() + if err != nil { + log.Error(err) + } + log.Debugf("%s was executed as '%s' (pid: %d) running as user %s (uid: %s) with group %s (gid: %s)", Branding.FullName, exe, p.Pid, u.Username, u.Uid, g.Name, u.Gid) +} diff --git a/pkg/cfg/cfg_test.go b/pkg/cfg/cfg_test.go index 86fb5ead..b5ede8e2 100644 --- a/pkg/cfg/cfg_test.go +++ b/pkg/cfg/cfg_test.go @@ -1,30 +1,280 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package cfg import ( + "fmt" + "os" + "path/filepath" + "strings" "testing" - // "github.com/bnfinet/lasso/pkg/structs" - // log "github.com/Sirupsen/logrus" - log "github.com/Sirupsen/logrus" - "github.com/stretchr/testify/assert" -) -var ( - cfg CfgT + "github.com/stretchr/testify/assert" ) -func init() { - // log.SetLevel(log.DebugLevel) +func setUp(configFile string) { + os.Setenv("VOUCH_CONFIG", filepath.Join(os.Getenv("VOUCH_ROOT"), configFile)) + InitForTestPurposes() } func TestConfigParsing(t *testing.T) { + InitForTestPurposes() + Configure() + + // UnmarshalKey(Branding.LCName, &cfg) + log.Debugf("cfgPort %d", Cfg.Port) + log.Debugf("cfgDomains %s", Cfg.Domains[0]) + + assert.Equal(t, Cfg.Port, 9090) + + assert.NotEmpty(t, Cfg.JWT.MaxAge) + +} +func TestConfigEnvPrecedence(t *testing.T) { + t.Cleanup(cleanupEnv) - UnmarshalKey("lasso", &cfg) - log.Debugf("cfgPort %d", cfg.Port) - log.Debugf("cfgDomains %s", cfg.Domains[0]) + envVar := "OAUTH_CLIENT_SECRET" + envVal := "testing123" - assert.Equal(t, cfg.Port, 9090) - assert.Equal(t, cfg.Cookie.Name, "bnfSSO") + os.Setenv(envVar, envVal) + // Configure() + setUp("/config/testing/handler_login_url.yml") - assert.NotEmpty(t, cfg.JWT.MaxAge) + assert.Equal(t, envVal, GenOAuth.ClientSecret) + + // assert.NotEmpty(t, Cfg.JWT.MaxAge) + +} + +func TestConfigWithTLS(t *testing.T) { + tests := []struct { + name string + tlsKeyFile string + tlsCertFile string + wantErr bool + }{ + {"TLSConfigOK", "/path/to/key", "/path/to/cert", false}, + {"TLSConfigKONoCert", "/path/to/key", "", true}, + {"TLSConfigKONoKey", "", "/path/to/cert", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(cleanupEnv) + InitForTestPurposes() + Cfg.TLS.Cert = tt.tlsCertFile + Cfg.TLS.Key = tt.tlsKeyFile + err := ValidateConfiguration() + + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} +func TestSetGitHubDefaults(t *testing.T) { + InitForTestPurposesWithProvider("github") + assert.Equal(t, []string{"read:user"}, GenOAuth.Scopes) +} + +func TestSetGitHubDefaultsWithTeamWhitelist(t *testing.T) { + InitForTestPurposesWithProvider("github") + Cfg.TeamWhiteList = append(Cfg.TeamWhiteList, "org/team") + GenOAuth.Scopes = []string{} + + setDefaultsGitHub() + assert.Contains(t, GenOAuth.Scopes, "read:user") + assert.Contains(t, GenOAuth.Scopes, "read:org") +} + +func TestCheckConfigWithRSA(t *testing.T) { + setUp("config/testing/test_config_rsa.yml") + assert.Contains(t, Cfg.JWT.PrivateKeyFile, "config/testing/rsa.key") + assert.Contains(t, Cfg.JWT.PublicKeyFile, "config/testing/rsa.pub") +} + +func Test_claimToHeader(t *testing.T) { + tests := []struct { + name string + arg string + want string + wantErr bool + }{ + {"remove http://", "http://test.example.com", Cfg.Headers.ClaimHeader + "Test-Example-Com", false}, + {"remove https://", "https://test.example.com", Cfg.Headers.ClaimHeader + "Test-Example-Com", false}, + {"auth0 fix https://", "https://test.auth0.com/user", Cfg.Headers.ClaimHeader + "Test-Auth0-Com-User", false}, + {"cognito user:groups", "user:groups", Cfg.Headers.ClaimHeader + "User-Groups", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := claimToHeader(tt.arg) + if (err != nil) != tt.wantErr { + t.Errorf("claimToHeader() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("claimToHeader() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_configureFromEnvCfg(t *testing.T) { + t.Cleanup(cleanupEnv) + // each of these env vars holds a.. + // string + // get all the values + senv := []string{"VOUCH_LISTEN", "VOUCH_JWT_ISSUER", "VOUCH_JWT_SECRET", "VOUCH_HEADERS_JWT", + "VOUCH_HEADERS_USER", "VOUCH_HEADERS_QUERYSTRING", "VOUCH_HEADERS_REDIRECT", "VOUCH_HEADERS_SUCCESS", "VOUCH_HEADERS_ERROR", + "VOUCH_HEADERS_CLAIMHEADER", "VOUCH_HEADERS_ACCESSTOKEN", "VOUCH_HEADERS_IDTOKEN", "VOUCH_COOKIE_NAME", "VOUCH_COOKIE_DOMAIN", + "VOUCH_COOKIE_SAMESITE", "VOUCH_TESTURL", "VOUCH_SESSION_NAME", "VOUCH_SESSION_KEY", "VOUCH_DOCUMENT_ROOT", "VOUCH_SOCKETGROUP"} + // array of strings + saenv := []string{"VOUCH_DOMAINS", "VOUCH_WHITELIST", "VOUCH_TEAMWHITELIST", "VOUCH_HEADERS_CLAIMS", "VOUCH_TESTURLS", "VOUCH_POST_LOGOUT_REDIRECT_URIS"} + // int + ienv := []string{"VOUCH_PORT", "VOUCH_JWT_MAXAGE", "VOUCH_COOKIE_MAXAGE", "VOUCH_SESSION_MAXAGE", "VOUCH_WRITETIMEOUT", "VOUCH_READTIMEOUT", + "VOUCH_IDLETIMEOUT", "VOUCH_SOCKETMODE"} + // bool + benv := []string{"VOUCH_ALLOWALLUSERS", "VOUCH_PUBLICACCESS", "VOUCH_JWT_COMPRESS", "VOUCH_COOKIE_SECURE", + "VOUCH_COOKIE_HTTPONLY", "VOUCH_TESTING"} + + // populate environmental variables + svalue := "svalue" + for _, v := range senv { + os.Setenv(v, svalue) + } + // "VOUCH_LOGLEVEL" is special since logging is occurring during these tests, needs to be an actual level + os.Setenv("VOUCH_LOGLEVEL", "debug") + + savalue := []string{"arrayone", "arraytwo", "arraythree"} + + for _, v := range saenv { + os.Setenv(v, strings.Join(savalue, ",")) + t.Logf("savalue: %s", savalue) + } + ivalue := 1234 + for _, v := range ienv { + os.Setenv(v, fmt.Sprint(ivalue)) + } + bvalue := false + for _, v := range benv { + os.Setenv(v, fmt.Sprint(bvalue)) + } + + // run the thing + configureFromEnv() + scfg := []string{Cfg.Listen, Cfg.JWT.Issuer, Cfg.JWT.Secret, Cfg.Headers.JWT, + Cfg.Headers.User, Cfg.Headers.QueryString, Cfg.Headers.Redirect, Cfg.Headers.Success, Cfg.Headers.Error, + Cfg.Headers.ClaimHeader, Cfg.Headers.AccessToken, Cfg.Headers.IDToken, Cfg.Cookie.Name, Cfg.Cookie.Domain, + Cfg.Cookie.SameSite, Cfg.TestURL, Cfg.Session.Name, Cfg.Session.Key, Cfg.DocumentRoot, Cfg.SocketGroup, + } + + sacfg := [][]string{Cfg.Domains, Cfg.WhiteList, Cfg.TeamWhiteList, Cfg.Headers.Claims, Cfg.TestURLs, Cfg.LogoutRedirectURLs} + icfg := []int{Cfg.Port, Cfg.JWT.MaxAge, Cfg.Cookie.MaxAge, Cfg.WriteTimeout, Cfg.ReadTimeout, Cfg.IdleTimeout, Cfg.SocketMode} + bcfg := []bool{Cfg.AllowAllUsers, Cfg.PublicAccess, Cfg.JWT.Compress, + Cfg.Cookie.Secure, + Cfg.Cookie.HTTPOnly, + Cfg.Testing, + } + + tests := []struct { + name string + }{ + {"Cfg struct field should be populated from env var"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, Cfg.LogLevel, "debug", "Cfg.LogLevel is not debug") + for i, v := range scfg { + assert.Equal(t, svalue, v, fmt.Sprintf("%d: v is %s not %s", i, v, svalue)) + } + for _, v := range sacfg { + assert.Equal(t, savalue, v, "v is %+s not %+s", v, savalue) + } + for _, v := range icfg { + assert.Equal(t, ivalue, v, "v is %+s not %+s", v, ivalue) + } + for _, v := range bcfg { + assert.Equal(t, bvalue, v, "v is %+s not %+s", v, bvalue) + } + }) + } + +} + +func Test_configureFromEnvOAuth(t *testing.T) { + t.Cleanup(cleanupEnv) + + // each of these env vars holds a.. + // string + // get all the values + senv := []string{ + "OAUTH_PROVIDER", "OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET", "OAUTH_AUTH_URL", "OAUTH_TOKEN_URL", + "OAUTH_END_SESSION_ENDPOINT", "OAUTH_CALLBACK_URL", "OAUTH_USER_INFO_URL", "OAUTH_USER_TEAM_URL", "OAUTH_USER_ORG_URL", + "OAUTH_PREFERREDDOMAIN", "OAUTH_RELYING_PARTY_ID", + } + // array of strings + saenv := []string{"OAUTH_CALLBACK_URLS", "OAUTH_SCOPES"} + + // populate environmental variables + svalue := "svalue" + for _, v := range senv { + os.Setenv(v, svalue) + } + savalue := []string{"arrayone", "arraytwo", "arraythree"} + for _, v := range saenv { + os.Setenv(v, strings.Join(savalue, ",")) + t.Logf("savalue: %s", savalue) + } + + // run the thing + configureFromEnv() + + scfg := []string{ + GenOAuth.Provider, + GenOAuth.ClientID, + GenOAuth.ClientSecret, + GenOAuth.AuthURL, + GenOAuth.TokenURL, + GenOAuth.LogoutURL, + GenOAuth.RedirectURL, + GenOAuth.UserInfoURL, + GenOAuth.UserTeamURL, + GenOAuth.UserOrgURL, + GenOAuth.PreferredDomain, + GenOAuth.RelyingPartyId, + } + sacfg := [][]string{ + GenOAuth.RedirectURLs, + GenOAuth.Scopes, + } + + tests := []struct { + name string + }{ + {"OAuth struct field should be populated from env var"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for i, v := range scfg { + assert.Equal(t, svalue, v, fmt.Sprintf("%d: v is %s not %s", i, v, svalue)) + } + for i, v := range sacfg { + assert.Equal(t, savalue, v, fmt.Sprintf("%d: v is %s not %s", i, v, savalue)) + } + }) + } +} +func cleanupEnv() { + os.Clearenv() + os.Setenv(Branding.UCName+"_ROOT", RootDir) + Cfg = &Config{} + GenOAuth = &oauthConfig{} } diff --git a/pkg/cfg/jwt.go b/pkg/cfg/jwt.go new file mode 100644 index 00000000..0d4e5e78 --- /dev/null +++ b/pkg/cfg/jwt.go @@ -0,0 +1,43 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "os" + + securerandom "github.com/theckman/go-securerandom" +) + +func getOrGenerateJWTSecret() string { + b, err := os.ReadFile(secretFile) + if err == nil { + log.Info("jwt.secret read from " + secretFile) + } else { + // then generate a new secret and store it in the file + log.Debug(err) + log.Info("jwt.secret not found in " + secretFile) + log.Warn("generating random jwt.secret and storing it in " + secretFile) + + // make sure to create 256 bits for the secret + // see https://github.com/vouch/vouch-proxy/issues/54 + rstr, err := securerandom.Base64OfBytes(base64Bytes) + if err != nil { + log.Fatal(err) + } + b = []byte(rstr) + err = os.WriteFile(secretFile, b, 0600) + if err != nil { + log.Error(err) + logSysInfo() + } + } + return string(b) +} diff --git a/pkg/cfg/logging.go b/pkg/cfg/logging.go new file mode 100644 index 00000000..902eac40 --- /dev/null +++ b/pkg/cfg/logging.go @@ -0,0 +1,146 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "os" + "strconv" + + "github.com/spf13/viper" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type logging struct { + Logger *zap.SugaredLogger + FastLogger *zap.Logger + AtomicLogLevel zap.AtomicLevel + DefaultLogLevel zapcore.Level +} + +var ( + logger *zap.Logger + log *zap.SugaredLogger + + // Logging is the public interface to logging + Logging = &logging{ + AtomicLogLevel: zap.NewAtomicLevel(), + DefaultLogLevel: zap.InfoLevel, + } +) + +const cmdLineLoggingDefault = -2 + +func init() { + Logging.AtomicLogLevel = zap.NewAtomicLevel() + // zap needs to start at zapcore.DebugLevel so that it can then be decreased to a lesser level + Logging.AtomicLogLevel.SetLevel(zapcore.DebugLevel) + encoderCfg := zap.NewProductionEncoderConfig() + logger = zap.New(zapcore.NewCore( + zapcore.NewJSONEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + Logging.AtomicLogLevel, + )) + + defer logger.Sync() // flushes buffer, if any + log = logger.Sugar() + Logging.FastLogger = logger + Logging.Logger = log + // Logging.FastLogger = zap.L() + // Logging.Logger = zap.S() + // log = Logging.Logger + // log.Info("logger set") + +} + +func (logging) setLogLevel(lvl zapcore.Level) { + // https://github.com/uber-go/zap/blob/master/zapcore/level.go#L59 + if Logging.AtomicLogLevel.Level() != lvl { + log.Infof("setting LogLevel to %s", lvl) + Logging.AtomicLogLevel.SetLevel(lvl) + } +} + +func (logging) setLogLevelString(str string) { + if err := CmdLine.logLevel.Set(str); err != nil { + log.Fatal(err) + } + Logging.setLogLevel(*CmdLine.logLevel) +} + +func (logging) setDevelopmentLogger() { + // then configure the logger for development output + clone := Logging.FastLogger.WithOptions( + zap.WrapCore( + // func(zapcore.Core) zapcore.Core { + func(zapcore.Core) zapcore.Core { + return zapcore.NewCore(zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()), zapcore.AddSync(os.Stderr), Logging.AtomicLogLevel) + })) + // zap.ReplaceGlobals(clone) + log = clone.Sugar() + // Logging.FastLogger = log.Desugar() + // Logging.Logger = log + Logging.FastLogger = log.Desugar() + Logging.Logger = log + log.Infof("testing: %s, using development console logger", strconv.FormatBool(Cfg.Testing)) +} + +var configured = false + +func (logging) configure() { + // logging + + if configured { + return + } + + // then we weren't configured via command line, check the config file + if !viper.IsSet(Branding.LCName + ".logLevel") { + // then we weren't configured via the config file, set the default + Cfg.LogLevel = Logging.DefaultLogLevel.String() + } + + if Cfg.LogLevel != Logging.AtomicLogLevel.Level().String() { + // log.Errorf("Logging.configure() Logging.LogLevel %s Cfg.LogLevel %s", Logging.LogLeveLogging.String(), Cfg.LogLevel) + Logging.setLogLevelString(Cfg.LogLevel) + } + + // if we're supposed to run tests, run tests and exit + if *CmdLine.logTest { + Logging.cmdlineTestLogs() + } + + configured = true +} + +func (logging) configureFromCmdline() { + + if *CmdLine.logLevel != cmdLineLoggingDefault { + Logging.setLogLevel(*CmdLine.logLevel) // defaults to Logging.DefaultLogLevel which is zap.InfoLevel + log.Info("logging configured from cmdline") + // if we're supposed to run tests, run tests and exit + if *CmdLine.logTest { + Logging.cmdlineTestLogs() + } + + configured = true + } +} + +// in support of `./do.sh test_logging` +func (logging) cmdlineTestLogs() { + Logging.Logger.Error("error") + Logging.Logger.Warn("warn") + Logging.Logger.Info("info") + Logging.Logger.Debug("debug") + // Logging.Logger.Panic("panic") + os.Exit(0) +} diff --git a/pkg/cfg/logging_test.go b/pkg/cfg/logging_test.go new file mode 100644 index 00000000..b3683623 --- /dev/null +++ b/pkg/cfg/logging_test.go @@ -0,0 +1,51 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "fmt" + "testing" + + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func Test_logging_setLogLevel(t *testing.T) { + _, obs := observer.New(Logging.AtomicLogLevel) + // type args struct { + // } + tests := []struct { + name string + lvl zapcore.Level + }{ + {"debug", zapcore.DebugLevel}, + {"info", zapcore.InfoLevel}, + {"warn", zapcore.WarnLevel}, + {"error", zapcore.ErrorLevel}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + Logging.setLogLevel(tt.lvl) + Logging.Logger.Debugf("%s %d", tt.name, tt.lvl) + Logging.Logger.Infof("%s %d", tt.name, tt.lvl) + Logging.Logger.Warnf("%s %d", tt.name, tt.lvl) + Logging.Logger.Errorf("%s %d", tt.name, tt.lvl) + + for _, logEntry := range obs.All() { + fmt.Printf("logEntry: %+v", logEntry) + if logEntry.Level < tt.lvl { + t.Errorf("should not have log level of %s", logEntry.Level) + } + t.Logf("tt.name %s", tt.name) + } + }) + } +} diff --git a/pkg/cfg/oauth.go b/pkg/cfg/oauth.go new file mode 100644 index 00000000..5b4e90b3 --- /dev/null +++ b/pkg/cfg/oauth.go @@ -0,0 +1,346 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" +) + +var ( + // GenOAuth exported OAuth config variable + // TODO: GenOAuth and OAuthClient should be combined + GenOAuth = &oauthConfig{} + + // OAuthClient is the configured client which will call the provider + // this actually carries the oauth2 client ala oauthclient.Client(oauth2.NoContext, providerToken) + OAuthClient *oauth2.Config + // OAuthopts authentication options + OAuthopts []oauth2.AuthCodeOption + + // Providers static strings to test against + Providers = &OAuthProviders{ + Google: "google", + GitHub: "github", + IndieAuth: "indieauth", + ADFS: "adfs", + Azure: "azure", + OIDC: "oidc", + HomeAssistant: "homeassistant", + OpenStax: "openstax", + Nextcloud: "nextcloud", + Alibaba: "alibaba", + Discord: "discord", + } +) + +// OAuthProviders holds the stings for +type OAuthProviders struct { + Google string + GitHub string + IndieAuth string + ADFS string + Azure string + OIDC string + HomeAssistant string + OpenStax string + Nextcloud string + Alibaba string + Discord string +} + +// oauth config items endoint for access +// `envconfig` tag is for env var support +// https://github.com/kelseyhightower/envconfig +type oauthConfig struct { + Provider string `mapstructure:"provider"` + ClientID string `mapstructure:"client_id" envconfig:"client_id"` + ClientSecret string `mapstructure:"client_secret" envconfig:"client_secret"` + AuthURL string `mapstructure:"auth_url" envconfig:"auth_url"` + TokenURL string `mapstructure:"token_url" envconfig:"token_url"` + LogoutURL string `mapstructure:"end_session_endpoint" envconfig:"end_session_endpoint"` + RedirectURL string `mapstructure:"callback_url" envconfig:"callback_url"` + RedirectURLs []string `mapstructure:"callback_urls" envconfig:"callback_urls"` + RelyingPartyId string `mapstructure:"relying_party_id" envconfig:"relying_party_id"` + Scopes []string `mapstructure:"scopes"` + // pointer-to-pointer so that the default uninitialized value is nil + Claims **oauthClaimsConfig `mapstructure:"claims"` + UserInfoURL string `mapstructure:"user_info_url" envconfig:"user_info_url"` + UserTeamURL string `mapstructure:"user_team_url" envconfig:"user_team_url"` + UserOrgURL string `mapstructure:"user_org_url" envconfig:"user_org_url"` + PreferredDomain string `mapstructure:"preferredDomain"` + AzureToken string `mapstructure:"azure_token" envconfig:"azure_token"` + CodeChallengeMethod string `mapstructure:"code_challenge_method" envconfig:"code_challenge_method"` + // DiscordUseIDs defaults to false, maintaining the more common username checking behavior + // If set to true, match the Discord user's ID instead of their username + DiscordUseIDs bool `mapstructure:"discord_use_ids" envconfig:"discord_use_ids"` +} + +type oauthClaimsConfig struct { + UserInfo map[string]*oauthClaimValueConfig `mapstructure:"userinfo" json:"userinfo,omitempty"` + IDToken map[string]*oauthClaimValueConfig `mapstructure:"id_token" json:"id_token,omitempty"` +} + +type oauthClaimValueConfig struct { + Essential bool `mapstructure:"essential" json:"essential,omitempty"` + Value interface{} `mapstructure:"value" json:"value,omitempty"` + Values []interface{} `mapstructure:"values" json:"values,omitempty"` +} + +func configureOauth() error { + // OAuth defaults and client configuration + if err := UnmarshalKey("oauth", &GenOAuth); err != nil { + return err + } + if GenOAuth.Claims != nil { + claims, err := json.Marshal(GenOAuth.Claims) + if err != nil { + return err + } + log.Infof("setting OAuth param 'claims' to %s", claims) + OAuthopts = append(OAuthopts, oauth2.SetAuthURLParam("claims", string(claims))) + } + return nil +} + +func oauthBasicTest() error { + if GenOAuth.Provider != Providers.Google && + GenOAuth.Provider != Providers.GitHub && + GenOAuth.Provider != Providers.IndieAuth && + GenOAuth.Provider != Providers.HomeAssistant && + GenOAuth.Provider != Providers.ADFS && + GenOAuth.Provider != Providers.Azure && + GenOAuth.Provider != Providers.OIDC && + GenOAuth.Provider != Providers.OpenStax && + GenOAuth.Provider != Providers.Nextcloud && + GenOAuth.Provider != Providers.Alibaba && + GenOAuth.Provider != Providers.Discord { + return errors.New("configuration error: Unknown oauth provider: " + GenOAuth.Provider) + } + // OAuthconfig Checks + switch { + case GenOAuth.ClientID == "": + // everyone has a clientID + return errors.New("configuration error: oauth.client_id not found") + case GenOAuth.Provider != Providers.IndieAuth && GenOAuth.Provider != Providers.HomeAssistant && GenOAuth.Provider != Providers.ADFS && GenOAuth.Provider != Providers.OIDC && GenOAuth.ClientSecret == "": + // everyone except IndieAuth has a clientSecret + // ADFS and OIDC providers also do not require this, but can have it optionally set. + return errors.New("configuration error: oauth.client_secret not found") + case GenOAuth.Provider != Providers.Google && GenOAuth.AuthURL == "": + // everyone except IndieAuth and Google has an authURL + return errors.New("configuration error: oauth.auth_url not found") + case GenOAuth.Provider != Providers.Google && GenOAuth.Provider != Providers.IndieAuth && GenOAuth.Provider != Providers.HomeAssistant && GenOAuth.Provider != Providers.ADFS && GenOAuth.Provider != Providers.Azure && GenOAuth.UserInfoURL == "": + // everyone except IndieAuth, Google and ADFS has an userInfoURL, and Azure does not actively use it + return errors.New("configuration error: oauth.user_info_url not found") + case GenOAuth.Provider != Providers.Discord && GenOAuth.DiscordUseIDs: + return errors.New("configuration error: discord_use_ids is true but oauth.provider is not 'discord'") + case GenOAuth.CodeChallengeMethod != "" && (GenOAuth.CodeChallengeMethod != "plain" && GenOAuth.CodeChallengeMethod != "S256"): + return errors.New("configuration error: oauth.code_challenge_method must be either 'S256' or 'plain'") + case GenOAuth.Provider == Providers.Azure || GenOAuth.Provider == Providers.ADFS || GenOAuth.Provider == Providers.Nextcloud || GenOAuth.Provider == Providers.OIDC: + checkScopes([]string{"openid", "email", "profile"}) + } + + if GenOAuth.RedirectURL != "" { + if err := checkCallbackConfig(GenOAuth.RedirectURL); err != nil { + return err + } + } + if len(GenOAuth.RedirectURLs) > 0 { + for _, cb := range GenOAuth.RedirectURLs { + if err := checkCallbackConfig(cb); err != nil { + return err + } + } + } + + return nil +} + +func checkScopes(scopes []string) { + for _, s := range scopes { + if !arrContains(GenOAuth.Scopes, s) { + log.Warnf("Configuration Warning: for 'oauth.provider: %s', 'oauth.scopes' should usually contain: -%s", GenOAuth.Provider, strings.Join(scopes, " -")) + return + } + } +} + +// TODO: all of these methods should become `provider.SetDefaults()` or `provider.SetDefaults(*GenOAuth)` +func setProviderDefaults() { + if GenOAuth.Provider == Providers.Google { + setDefaultsGoogle() + // setDefaultsGoogle also configures the OAuthClient + } else if GenOAuth.Provider == Providers.GitHub { + setDefaultsGitHub() + configureOAuthClient() + } else if GenOAuth.Provider == Providers.ADFS { + setDefaultsADFS() + configureOAuthClient() + } else if GenOAuth.Provider == Providers.Azure { + setDefaultsAzure() + configureOAuthClient() + } else if GenOAuth.Provider == Providers.IndieAuth { + GenOAuth.CodeChallengeMethod = "S256" + configureOAuthClient() + } else if GenOAuth.Provider == Providers.Discord { + setDefaultsDiscord() + configureOAuthClient() + } else { + // OIDC, OpenStax, Nextcloud + configureOAuthClient() + } +} + +func setDefaultsGoogle() { + log.Info("configuring Google OAuth") + GenOAuth.UserInfoURL = "https://www.googleapis.com/oauth2/v3/userinfo" + if len(GenOAuth.Scopes) == 0 { + // You have to select a scope from + // https://developers.google.com/identity/protocols/googlescopes#google_sign-in + GenOAuth.Scopes = []string{"email"} + } + OAuthClient = &oauth2.Config{ + ClientID: GenOAuth.ClientID, + ClientSecret: GenOAuth.ClientSecret, + Scopes: GenOAuth.Scopes, + Endpoint: google.Endpoint, + RedirectURL: GenOAuth.RedirectURL, + } + if GenOAuth.PreferredDomain != "" { + log.Infof("setting Google OAuth preferred login domain param 'hd' to %s", GenOAuth.PreferredDomain) + OAuthopts = append(OAuthopts, oauth2.SetAuthURLParam("hd", GenOAuth.PreferredDomain)) + } + GenOAuth.CodeChallengeMethod = "S256" +} + +func setDefaultsADFS() { + log.Info("configuring ADFS OAuth") + + if GenOAuth.RelyingPartyId == "" { + GenOAuth.RelyingPartyId = GenOAuth.RedirectURL + } + + OAuthopts = append(OAuthopts, oauth2.SetAuthURLParam("resource", GenOAuth.RelyingPartyId)) +} + +func setDefaultsAzure() { + log.Info("configuring Azure OAuth") + if len(GenOAuth.AzureToken) == 0 { + log.Info("Using Default Azure Token: access_token") + GenOAuth.AzureToken = "access_token" + } else if GenOAuth.AzureToken == "access_token" { + log.Info("Using Azure Token: access_token") + } else if GenOAuth.AzureToken == "id_token" { + log.Info("Using Azure Token: id_token") + } else { + log.Fatal("'oauth.azure_token' must be either 'access_token' or 'id_token'") + } + GenOAuth.CodeChallengeMethod = "S256" +} + +func setDefaultsGitHub() { + // log.Info("configuring GitHub OAuth") + if GenOAuth.AuthURL == "" { + GenOAuth.AuthURL = github.Endpoint.AuthURL + } + if GenOAuth.TokenURL == "" { + GenOAuth.TokenURL = github.Endpoint.TokenURL + } + if GenOAuth.UserInfoURL == "" { + GenOAuth.UserInfoURL = "https://api.github.com/user" + } + if GenOAuth.UserTeamURL == "" { + GenOAuth.UserTeamURL = "https://api.github.com/orgs/:org_id/teams/:team_slug/memberships/:username" + } + if GenOAuth.UserOrgURL == "" { + GenOAuth.UserOrgURL = "https://api.github.com/orgs/:org_id/members/:username" + } + if len(GenOAuth.Scopes) == 0 { + // https://github.com/vouch/vouch-proxy/issues/63 + // https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ + GenOAuth.Scopes = []string{"read:user"} + + if len(Cfg.TeamWhiteList) > 0 { + GenOAuth.Scopes = append(GenOAuth.Scopes, "read:org") + } + } + GenOAuth.CodeChallengeMethod = "S256" +} + +func setDefaultsDiscord() { + // log.Info("configuring GitHub OAuth") + if GenOAuth.AuthURL == "" { + GenOAuth.AuthURL = "https://discord.com/oauth2/authorize" + } + if GenOAuth.TokenURL == "" { + GenOAuth.TokenURL = "https://discord.com/api/oauth2/token" + } + if GenOAuth.UserInfoURL == "" { + GenOAuth.UserInfoURL = "https://discord.com/api/users/@me" + } + if len(GenOAuth.Scopes) == 0 { + //Required for UserInfo URL + //https://discord.com/developers/docs/resources/user#get-current-user + GenOAuth.Scopes = []string{"identify", "email"} + } + GenOAuth.CodeChallengeMethod = "S256" +} + +func configureOAuthClient() { + log.Infof("configuring %s OAuth with Endpoint %s", GenOAuth.Provider, GenOAuth.AuthURL) + OAuthClient = &oauth2.Config{ + ClientID: GenOAuth.ClientID, + ClientSecret: GenOAuth.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: GenOAuth.AuthURL, + TokenURL: GenOAuth.TokenURL, + }, + RedirectURL: GenOAuth.RedirectURL, + Scopes: GenOAuth.Scopes, + } +} + +func checkCallbackConfig(url string) error { + if !strings.Contains(url, "/auth") { + log.Errorf("configuration error: oauth.callback_url (%s) should almost always point at the vouch-proxy '/auth' endpoint", url) + } + + found := false + for _, d := range append(Cfg.Domains, Cfg.Cookie.Domain) { + if d != "" && strings.Contains(url, d) { + found = true + break + } + } + if !found { + return fmt.Errorf("configuration error: oauth.callback_url (%s) must be within a configured domains where the cookie will be set: either `vouch.domains` %s or `vouch.cookie.domain` %s", + url, + Cfg.Domains, + Cfg.Cookie.Domain) + } + + return nil +} + +func arrContains(arr []string, str string) bool { + for _, v := range arr { + if v == str { + return true + } + } + return false +} diff --git a/pkg/cfg/oauth_test.go b/pkg/cfg/oauth_test.go new file mode 100644 index 00000000..055d5648 --- /dev/null +++ b/pkg/cfg/oauth_test.go @@ -0,0 +1,45 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_checkCallbackConfig(t *testing.T) { + setUp("/config/testing/handler_login_url.yml") + + tests := []struct { + name string + url string + wantErr bool + }{ + {"correct", "http://vouch.example.com:9090/auth", false}, + {"bad", "http://vouch.notgonna.com:9090/somewhereelse", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := checkCallbackConfig(tt.url); (err != nil) != tt.wantErr { + t.Errorf("checkCallbackConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_configureOAuthWithClaims(t *testing.T) { + setUp("/config/testing/test_config_oauth_claims.yml") + authCodeURL, err := url.Parse(OAuthClient.AuthCodeURL("state", OAuthopts...)) + assert.Nil(t, err) + assert.Equal(t, authCodeURL.Query().Get("claims"), `{"userinfo":{"email":{"essential":true},"email_verified":{"essential":true},"given_name":{"essential":true},"http://example.info/claims/groups":null,"nickname":null,"picture":null},"id_token":{"acr":{"values":["urn:mace:incommon:iap:silver"]},"auth_time":{"essential":true}}}`) +} diff --git a/pkg/cfg/tls.go b/pkg/cfg/tls.go new file mode 100644 index 00000000..020bcfca --- /dev/null +++ b/pkg/cfg/tls.go @@ -0,0 +1,69 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "crypto/tls" +) + +// TLSConfig config returns a *tls.Config with the specified profile (modern, intermediate, old, default) configuration. +func TLSConfig(profile string) *tls.Config { + c := &tls.Config{} + + // Source: https://ssl-config.mozilla.org/#server=go&version=1.14&config=modern&hsts=false&guideline=5.6 + switch profile { + case "modern": + c = &tls.Config{ + MinVersion: tls.VersionTLS13, + } + case "intermediate": + c = &tls.Config{ + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + }, + } + case "old": + c = &tls.Config{ + MinVersion: tls.VersionTLS10, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_CBC_SHA256, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, + }, + } + } + + return c +} diff --git a/pkg/cfg/tls_test.go b/pkg/cfg/tls_test.go new file mode 100644 index 00000000..f2bf2ca7 --- /dev/null +++ b/pkg/cfg/tls_test.go @@ -0,0 +1,37 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cfg + +import ( + "crypto/tls" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTLSConfig(t *testing.T) { + tests := []struct { + name string + profile string + wantTLSMinVersion uint16 + }{ + {"TLSDefaultProfile", "", 0}, + {"TLSModernProfile", "modern", tls.VersionTLS13}, + {"TLSIntermediateProfile", "intermediate", tls.VersionTLS12}, + {"TLSOldProfile", "old", tls.VersionTLS10}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tlsConfig := TLSConfig(tt.profile) + assert.Equal(t, tt.wantTLSMinVersion, tlsConfig.MinVersion) + }) + } +} diff --git a/pkg/context/context.go b/pkg/context/context.go deleted file mode 100644 index 3cc719bb..00000000 --- a/pkg/context/context.go +++ /dev/null @@ -1,10 +0,0 @@ -package context - -// Key named keys for context map -type Key string - -func (c Key) String() string { - return "mypackage context key " + string(c) -} - -var StatusCode = Key("statusCode") diff --git a/pkg/cookie/cookie.go b/pkg/cookie/cookie.go index 72d3dd3e..4f8d2de9 100644 --- a/pkg/cookie/cookie.go +++ b/pkg/cookie/cookie.go @@ -1,30 +1,58 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package cookie import ( "errors" + "fmt" "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" - // "github.com/bnfinet/lasso/pkg/structs" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/domains" - log "github.com/Sirupsen/logrus" + // "github.com/vouch/vouch-proxy/pkg/structs" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/domains" + "go.uber.org/zap" ) -var defaultMaxAge = cfg.Cfg.JWT.MaxAge * 60 +const maxCookieSize = 4000 +const maxCookieParts = 32 + +var log *zap.SugaredLogger +var sameSite http.SameSite + +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger + sameSite = SameSite() +} // SetCookie http func SetCookie(w http.ResponseWriter, r *http.Request, val string) { - setCookie(w, r, val, defaultMaxAge) + setCookie(w, r, val, cfg.Cfg.Cookie.MaxAge*60) // convert minutes to seconds } func setCookie(w http.ResponseWriter, r *http.Request, val string, maxAge int) { // foreach domain - if maxAge == 0 { - maxAge = defaultMaxAge - } domain := domains.Matches(r.Host) - // log.Debugf("cookie %s expires %d", cfg.Cfg.Cookie.Name, expires) - http.SetCookie(w, &http.Cookie{ + // Allow overriding the cookie domain in the config file + if cfg.Cfg.Cookie.Domain != "" { + domain = cfg.Cfg.Cookie.Domain + } + log.Debugf("setting the cookie domain to %v", domain) + + // create the cookie and then test to see if it's too big + cookie := http.Cookie{ Name: cfg.Cfg.Cookie.Name, Value: val, Path: "/", @@ -32,23 +60,145 @@ func setCookie(w http.ResponseWriter, r *http.Request, val string, maxAge int) { MaxAge: maxAge, Secure: cfg.Cfg.Cookie.Secure, HttpOnly: cfg.Cfg.Cookie.HTTPOnly, - }) + SameSite: sameSite, + } + cookieSize := len(cookie.String()) + // Cookies have a max size of 4096 bytes, but to support most browsers, we should stay below 4000 bytes + // https://tools.ietf.org/html/rfc6265#section-6.1 + // http://browsercookielimits.squawky.net/ + if cookieSize > maxCookieSize { + // https://www.lifewire.com/cookie-limit-per-domain-3466809 + log.Warnf("cookie size: %d. cookie sizes over ~4093 bytes(depending on the browser and platform) have shown to cause issues or simply aren't supported.", cookieSize) + emptyCookie := cookie + emptyCookie.Value = "" + emptyCookieSize := len(emptyCookie.String()) + cookieParts := splitCookie(val, maxCookieSize-emptyCookieSize) + for i, cookiePart := range cookieParts { + // Cookies are named 1of3, 2of3, 3of3 + cookie.Name = fmt.Sprintf("%s_%dof%d", cfg.Cfg.Cookie.Name, i+1, len(cookieParts)) + cookie.Value = cookiePart + http.SetCookie(w, &cookie) + } + } else { + http.SetCookie(w, &cookie) + } } -// Cookie get the lasso jwt cookie +// Cookie get the vouch jwt cookie func Cookie(r *http.Request) (string, error) { - cookie, err := r.Cookie(cfg.Cfg.Cookie.Name) - if err != nil { - return "", err + + var cookieParts []string + var numParts = -1 + + var err error + cookies := r.Cookies() + // Get the remaining parts + // search for cookie parts in order + // this is the hot path so we're trying to only walk once + for _, cookie := range cookies { + if cookie.Name == cfg.Cfg.Cookie.Name { + return cookie.Value, nil + } + cookieUnder := fmt.Sprintf("%s_", cfg.Cfg.Cookie.Name) + if strings.HasPrefix(cookie.Name, cookieUnder) { + log.Debugw("cookie", + "cookieName", cookie.Name, + "cookieValue", cookie.Value, + ) + xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) + xyArray := strings.Split(xOFy, "of") + if numParts == -1 { // then its uninitialized + if numParts, err = strconv.Atoi(xyArray[1]); err != nil { + return "", fmt.Errorf("multipart cookie fail: %s", err) + } + if numParts < 1 || numParts > maxCookieParts { + return "", fmt.Errorf("multipart cookie fail: invalid part count %s", xOFy) + } + log.Debugf("make cookieParts of size %d", numParts) + cookieParts = make([]string, numParts) + } + var i int + if i, err = strconv.Atoi(xyArray[0]); err != nil { + return "", fmt.Errorf("multipart cookie fail: %s", err) + } + if i > numParts { + return "", fmt.Errorf("multipart cookie fail: invalid part count %s", xOFy) + } + cookieParts[i-1] = cookie.Value + } + } - if cookie.Value == "" { - return "", errors.New("Cookie token empty") + // combinedCookieStr := combinedCookie.String() + combinedCookieStr := strings.Join(cookieParts, "") + if combinedCookieStr == "" { + return "", errors.New("cookie token empty") } - log.Debugf("cookie %s: %s", cfg.Cfg.Cookie.Name, cookie.Value) - return cookie.Value, err + + log.Debugw("combined cookie", + "cookieValue", combinedCookieStr, + ) + return combinedCookieStr, err } // ClearCookie get rid of the existing cookie func ClearCookie(w http.ResponseWriter, r *http.Request) { - setCookie(w, r, "delete", -1) + cookies := r.Cookies() + domain := domains.Matches(r.Host) + // Allow overriding the cookie domain in the config file + if cfg.Cfg.Cookie.Domain != "" { + domain = cfg.Cfg.Cookie.Domain + log.Debugf("setting the cookie domain to %v", domain) + } + // search for cookie parts + for _, cookie := range cookies { + if strings.HasPrefix(cookie.Name, cfg.Cfg.Cookie.Name) { + log.Debugf("deleting cookie: %s", cookie.Name) + http.SetCookie(w, &http.Cookie{ + Name: cookie.Name, + Value: "delete", + Path: "/", + Domain: domain, + // https://stackoverflow.com/questions/5285940/correct-way-to-delete-cookies-server-side + Expires: time.Unix(0, 0), // system dependent but usually Thu, 01 Jan 1970 00:00:00 GMT + Secure: cfg.Cfg.Cookie.Secure, + HttpOnly: cfg.Cfg.Cookie.HTTPOnly, + }) + } + } +} + +// SameSite return cfg.Cfg.Cookie.SameSite as http.Samesite +// if cfg.Cfg.Cookie.SameSite is unconfigured return http.SameSite(0) +// see https://github.com/vouch/vouch-proxy/issues/210 +func SameSite() http.SameSite { + sameSite := http.SameSite(0) + if cfg.Cfg.Cookie.SameSite != "" { + switch strings.ToLower(cfg.Cfg.Cookie.SameSite) { + case "lax": + sameSite = http.SameSiteLaxMode + case "strict": + sameSite = http.SameSiteStrictMode + case "none": + if !cfg.Cfg.Cookie.Secure { + log.Error("SameSite cookie attribute with sameSite=none should also be specified with secure=true.") + } + sameSite = http.SameSiteNoneMode + } + } + return sameSite +} + +// splitCookie separate string into several strings of specified length +func splitCookie(longString string, maxLen int) []string { + splits := make([]string, 0) + + var l, r int + for l, r = 0, maxLen; r < len(longString); l, r = r, r+maxLen { + for !utf8.RuneStart(longString[r]) { + r-- + } + splits = append(splits, longString[l:r]) + } + splits = append(splits, longString[l:]) + return splits } diff --git a/pkg/cookie/cookie_test.go b/pkg/cookie/cookie_test.go new file mode 100644 index 00000000..67ffe2d1 --- /dev/null +++ b/pkg/cookie/cookie_test.go @@ -0,0 +1,112 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package cookie + +import ( + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func init() { + cfg.InitForTestPurposes() + Configure() +} + +func TestSplitCookie(t *testing.T) { + type args struct { + longString string + maxLen int + } + tests := []struct { + name string + args args + want []string + }{ + {"small split", args{"AAAbbbCCCdddEEEfffGGGhhhIIIjjj", 3}, []string{"AAA", "bbb", "CCC", "ddd", "EEE", "fff", "GGG", "hhh", "III", "jjj"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := splitCookie(tt.args.longString, tt.args.maxLen); !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitCookie() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCookie(t *testing.T) { + cfg.Cfg.Cookie.Name = "_alpha_beta" + ckValue1 := "charlie" + ckValue2 := "delta" + expectedValue := fmt.Sprintf("%s%s", ckValue1, ckValue2) + + r1 := &http.Request{ + Header: map[string][]string{ + "Cookie": { + fmt.Sprintf("%s_1of2=%s", cfg.Cfg.Cookie.Name, ckValue1), + fmt.Sprintf("%s_2of2=%s", cfg.Cfg.Cookie.Name, ckValue2), + }, + }, + } + + r2TooBigCookieHeader := map[string][]string{} + parts := maxCookieParts + 1 + for i := 1; i < parts; i++ { + r2TooBigCookieHeader["Cookie"] = append(r2TooBigCookieHeader["Cookie"], fmt.Sprintf("%s_%dof%d=%d", cfg.Cfg.Cookie.Name, i, parts, i)) + } + r2 := &http.Request{ + Header: r2TooBigCookieHeader, + } + + r3 := &http.Request{ + Header: map[string][]string{"Cookie": {fmt.Sprintf("%s_1of100000000000=%s", cfg.Cfg.Cookie.Name, ckValue1)}}, + } + + r4 := &http.Request{ + Header: map[string][]string{"Cookie": {fmt.Sprintf("%s_100000000000000of32=%s", cfg.Cfg.Cookie.Name, ckValue1)}}, + } + + tests := []struct { + name string // description of this test case + r *http.Request + want string + wantErr bool + }{ + {"_alpha_beta", r1, expectedValue, false}, + {"too many parts", r2, "very big", true}, + {"just one but it claims to be big", r3, "very very big", true}, + {"just one but it has a big X in XofY", r4, "very very big", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // prepare the test by rendering the cookies into the response object + tt.r.Cookies() + + got, gotErr := Cookie(tt.r) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("Cookie() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("Cookie() succeeded unexpectedly") + } + // should match + if got != tt.want { + t.Errorf("Cookie() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cors/cors.go b/pkg/cors/cors.go deleted file mode 100644 index 0baa19bd..00000000 --- a/pkg/cors/cors.go +++ /dev/null @@ -1,18 +0,0 @@ -package cors - -import ( - "net/http" - - log "github.com/Sirupsen/logrus" -) - -// AllowAll is middle ware to set Access-Control-Allow-Origin: * -func AllowAll(nextHandler http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Credentials", "true") - log.Debugf("setting Access-Control-Allow-Origin header to %s", origin) - nextHandler.ServeHTTP(w, r) - } -} diff --git a/pkg/domains/domains.go b/pkg/domains/domains.go index 076e550e..ecbb62ea 100644 --- a/pkg/domains/domains.go +++ b/pkg/domains/domains.go @@ -1,32 +1,76 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package domains import ( + "sort" "strings" - "github.com/bnfinet/lasso/pkg/cfg" - log "github.com/Sirupsen/logrus" + "github.com/vouch/vouch-proxy/pkg/cfg" + "go.uber.org/zap" ) -// TODO sort domains by length from longest to shortest -// https://play.golang.org/p/N6GbEgBffd +var log *zap.SugaredLogger + +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger + sort.Sort(ByLengthDesc(cfg.Cfg.Domains)) +} // Matches returns one of the domains we're configured for -// TODO return all matches func Matches(s string) string { - for i, v := range cfg.Cfg.Domains { - log.Debugf("array value at [%d]=%v", i, v) - if strings.Contains(s, v) { - return v + if strings.Contains(s, ":") { + // then we have a port and we just want to check the host + split := strings.Split(s, ":") + log.Debugf("removing port from %s to test domain %s", s, split[0]) + s = split[0] + } + + if len(cfg.Cfg.Domains) > 0 { + for i, v := range cfg.Cfg.Domains { + if s == v || strings.HasSuffix(s, "."+v) { + log.Debugf("domain %s matched array value at [%d]=%v", s, i, v) + return v + } } + log.Warnf("domain %s not found in any domains %v", s, cfg.Cfg.Domains) } return "" } -// IsUnderManagement check if string contains a lasso managed domain -func IsUnderManagement(s string) bool { - match := Matches(s) - if match != "" { - return true +// IsUnderManagement check if an email is under vouch-managed domain +func IsUnderManagement(email string) bool { + split := strings.Split(email, "@") + if len(split) != 2 { + log.Warnf("not a valid email: %s", email) + return false } - return false + + match := Matches(split[1]) + return match != "" +} + +// ByLengthDesc sort from +// https://play.golang.org/p/N6GbEgBffd +type ByLengthDesc []string + +func (s ByLengthDesc) Len() int { + return len(s) +} +func (s ByLengthDesc) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// this differs by offing the longest first +func (s ByLengthDesc) Less(i, j int) bool { + return len(s[j]) < len(s[i]) } diff --git a/pkg/domains/domains_test.go b/pkg/domains/domains_test.go new file mode 100644 index 00000000..c65f5fa8 --- /dev/null +++ b/pkg/domains/domains_test.go @@ -0,0 +1,52 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package domains + +import ( + "github.com/stretchr/testify/assert" + "testing" + + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func init() { + cfg.InitForTestPurposes() + cfg.Cfg.Domains = []string{"vouch.github.io", "sub.test.mydomain.com", "test.mydomain.com"} + Configure() +} + +func TestIsUnderManagement(t *testing.T) { + assert.True(t, IsUnderManagement("test@vouch.github.io")) + assert.True(t, IsUnderManagement("test@sub.vouch.github.io")) + assert.True(t, IsUnderManagement("test@test.mydomain.com")) + assert.True(t, IsUnderManagement("test@sub.test.mydomain.com")) + + assert.False(t, IsUnderManagement("test@example.com")) + assert.False(t, IsUnderManagement("vouch.github.io@example.com")) + assert.False(t, IsUnderManagement("test-vouch.github.io@example.com")) + assert.False(t, IsUnderManagement("test@vouch.github.io.com")) +} + +func TestMatches(t *testing.T) { + // Full email should not be accepted + assert.Equal(t, "", Matches("test@vouch.github.io")) + + assert.Equal(t, "vouch.github.io", Matches("vouch.github.io")) + assert.Equal(t, "vouch.github.io", Matches("sub.vouch.github.io")) + assert.Equal(t, "", Matches("a-different-vouch.github.io")) + + assert.Equal(t, "", Matches("mydomain.com")) + + assert.Equal(t, "test.mydomain.com", Matches("test.mydomain.com")) + assert.Equal(t, "sub.test.mydomain.com", Matches("sub.test.mydomain.com")) + assert.Equal(t, "sub.test.mydomain.com", Matches("subsub.sub.test.mydomain.com")) + assert.Equal(t, "test.mydomain.com", Matches("other.test.mydomain.com")) +} diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go new file mode 100644 index 00000000..6636289f --- /dev/null +++ b/pkg/healthcheck/healthcheck.go @@ -0,0 +1,64 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package healthcheck + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "go.uber.org/zap" +) + +var log *zap.SugaredLogger + +func configure() { + // cfg.ConfigureLogger() + log = cfg.Logging.Logger + if !cfg.Cfg.Testing { + cfg.Logging.AtomicLogLevel.SetLevel(zap.ErrorLevel) + } +} + +// CheckAndExitIfIsHealthCheck healthcheck is a command line flag `-healthcheck` +func CheckAndExitIfIsHealthCheck() { + + if *cfg.CmdLine.IsHealthCheck { + configure() + healthcheck() + } +} + +func healthcheck() { + url := fmt.Sprintf("http://%s:%d/healthcheck", cfg.Cfg.Listen, cfg.Cfg.Port) + log.Debugf("Invoking healthcheck on %s", url) + // #nosec - turn off gosec checking which flags `http.Get(url)` + resp, err := http.Get(url) + if err == nil { + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err == nil { + var result map[string]interface{} + jsonErr := json.Unmarshal(body, &result) + if jsonErr == nil { + if result["ok"] == true { + log.Debugf("Healthcheck succeeded for %s", url) + os.Exit(0) + } + } + } + } + log.Errorf("Healthcheck failed for %s", url) + os.Exit(1) +} diff --git a/pkg/jwtmanager/jwtcache.go b/pkg/jwtmanager/jwtcache.go new file mode 100644 index 00000000..b12ab188 --- /dev/null +++ b/pkg/jwtmanager/jwtcache.go @@ -0,0 +1,113 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package jwtmanager + +import ( + "net/http" + "strings" + "time" + + cache "github.com/patrickmn/go-cache" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/responses" +) + +// Cache in memory temporary store for responses from /validate for jwt +var Cache *cache.Cache +var expire int = 20 // default 20 minutes +var dExp time.Duration + +func cacheConfigure() { + + if cfg.Cfg.JWT.MaxAge < expire { + expire = cfg.Cfg.JWT.MaxAge + } + dExp = time.Duration(expire) * time.Minute + purgeCheck := dExp / 5 + // log.Debugf("cacheConfigure expire %d dExp %d purgecheck %d", expire, dExp, purgeCheck) + Cache = cache.New(dExp, purgeCheck) + log.Infof("jwtcache: the returned headers for a valid jwt will be cached for %d minutes", expire) +} + +// CachedResponse caches the JWT response +// type CachedResponse struct { +// *CaptureWriter +// rawResponse []byte +// } + +// JWTCacheHandler looks for a JWT and... +// returns a cached response +// or passes the request to /validate +// all tests for JWTCacheHandler are present in `handlers/validate_test.go` to avoid circular imports +func JWTCacheHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + jwt := FindJWT(r) + + // check to see if we have headers cached for this jwt + if jwt != "" { + if resp, found := Cache.Get(jwt); found { + // found it in cache! + logger.Debug("/validate found response headers for jwt in cache") + // TODO: instead of the copy for each, can we just append the whole blob? + // or better still can we just cache the entire response including 200OK? + for k, v := range resp.(http.Header) { + w.Header().Add(k, strings.Join(v, ",")) + + } + + responses.OK200(w, r) + + return + } + } + + next.ServeHTTP(w, r) + + if jwt != "" && + r.Context().Err() == nil { + // see responses.addErrandCancelRequest() + // r.Context().Done() is still open + // cache the response headers for this jwt + // log.Debug("setting cache for %+v", w.Header().Clone()) + + claims, err := ClaimsFromJWT(jwt) + if err != nil { + log.Error("very unusual error, we found a jwt for /validate but we couldn't parse it for claims while setting it into cache, returning") + return + // log.Debugf("claims expire, time.now.unix, dExp %d - %d = %d > %d", claims.ExpiresAt, now, claims.ExpiresAt-now, int64(dExp)) + // log.Debugf("time.Duration((claims.ExpiresAt-time.Now().Unix())*time.Second.Nanoseconds()) %d", time.Duration((claims.ExpiresAt-time.Now().Unix())*time.Second.Nanoseconds())) + } + + cacheExp := getCacheExpirationDuration(claims) + Cache.Set(jwt, w.Header().Clone(), cacheExp) + } + }) +} + +// getCacheExpirationDuration - return time.Duration til the jwt should be purged from cache +// first see if the jwt's expiration will arrive before the cache expiration +// if this jwt expires in 10 minutes then we don't want to cache it for 20 +// this might happen if the jwt expiration is set to 240 minutes, and the user last logged into the IdP 230 minutes ago +// then the user went away, cache was purged and now they return with 10 minutes left before token expiration +func getCacheExpirationDuration(claims *VouchClaims) time.Duration { + + now := time.Now().Unix() + expiresAt := now + int64(dExp/time.Second) + + if claims.ExpiresAt.Unix() < expiresAt { + jwtExpiresIn := time.Duration((claims.ExpiresAt.Unix() - now) * int64(time.Second)) + log.Debugf("cache default expiration (%d) is after jwt expiration (%d). setting cache expiration to claim expiration for this entry", dExp, jwtExpiresIn) + return jwtExpiresIn + } + return dExp +} diff --git a/pkg/jwtmanager/jwtcache_test.go b/pkg/jwtmanager/jwtcache_test.go new file mode 100644 index 00000000..fe5ed945 --- /dev/null +++ b/pkg/jwtmanager/jwtcache_test.go @@ -0,0 +1,50 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package jwtmanager + +import ( + "fmt" + "reflect" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func Test_getCacheExpirationDuration(t *testing.T) { + // default cache expire is 20 minutes, so we test +/- 5 minutes of that + expire = 17 + now := time.Now() + + claimsA := lc + claimsA.ExpiresAt = jwt.NewNumericDate(now.Add(time.Minute * time.Duration(expire+5))) + + claimsB := lc + dBexp := time.Minute * time.Duration(expire-5) + claimsB.ExpiresAt = jwt.NewNumericDate(now.Add(dBexp)) + + tests := []struct { + name string + claims *VouchClaims + want time.Duration + }{ + {fmt.Sprintf("should equal %d", expire), &claimsA, dExp}, // dExp is the default expiration duration + {fmt.Sprintf("should equal %d -5", expire), &claimsB, dBexp}, + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getCacheExpirationDuration(tt.claims); !reflect.DeepEqual(got, tt.want) { + t.Errorf("getCacheExpirationDuration() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/jwtmanager/jwtmanager.go b/pkg/jwtmanager/jwtmanager.go index 1d38d3c7..b2e73b75 100644 --- a/pkg/jwtmanager/jwtmanager.go +++ b/pkg/jwtmanager/jwtmanager.go @@ -1,3 +1,13 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package jwtmanager import ( @@ -6,197 +16,256 @@ import ( "encoding/base64" "errors" "fmt" - "io/ioutil" + "io" + "net/http" "strings" "time" - log "github.com/Sirupsen/logrus" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/structs" + jwt "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" - jwt "github.com/dgrijalva/jwt-go" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/structs" ) -// const numSites = 2 +const comma = "," -// LassoClaims jwt Claims specific to lasso -type LassoClaims struct { - Email string `json:"email"` - Sites []string `json:"sites"` // tempting to make this a map but the array is fewer characters in the jwt - jwt.StandardClaims +// VouchClaims jwt Claims specific to vouch +type VouchClaims struct { + Username string `json:"username"` + CustomClaims map[string]interface{} + PAccessToken string + PIdToken string + jwt.RegisteredClaims } -// StandardClaims jwt.StandardClaims implimentation -var StandardClaims jwt.StandardClaims +// RegisteredClaims jwt.RegisteredClaims implementation +var RegisteredClaims jwt.RegisteredClaims -// Sites just testing -var Sites []string +var logger *zap.Logger +var log *zap.SugaredLogger +var aud []string -func init() { - StandardClaims = jwt.StandardClaims{ - Issuer: cfg.Cfg.JWT.Issuer, +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger + logger = cfg.Logging.FastLogger + cacheConfigure() + aud = audience() + RegisteredClaims = jwt.RegisteredClaims{ + Issuer: cfg.Cfg.JWT.Issuer, + Audience: aud, } - Sites = make([]string, 0) +} +// `aud` of the issued JWT https://tools.ietf.org/html/rfc7519#section-4.1.3 +func audience() []string { + aud := make([]string, 0) + // TODO: the Sites that end up in the JWT come from here + // if we add fine grain ability (ACL?) to the equation + // then we're going to have to add something fancier here for i := 0; i < len(cfg.Cfg.Domains); i++ { - Sites = append(Sites, cfg.Cfg.Domains[i]) + aud = append(aud, cfg.Cfg.Domains[i]) + } + if cfg.Cfg.Cookie.Domain != "" { + aud = append(aud, cfg.Cfg.Cookie.Domain) } + return aud } -// CreateUserTokenString converts user to signed jwt -func CreateUserTokenString(u structs.User) string { +// NewVPJWT issue a signed Vouch Proxy JWT for a user +func NewVPJWT(u structs.User, customClaims structs.CustomClaims, ptokens structs.PTokens) (string, error) { // User`token` - claims := LassoClaims{ - u.Email, - Sites, - StandardClaims, + // u.PrepareUserData() + claims := VouchClaims{ + u.Username, + customClaims.Claims, + ptokens.PAccessToken, + ptokens.PIdToken, + RegisteredClaims, } - claims.StandardClaims.ExpiresAt = time.Now().Add(time.Minute * time.Duration(cfg.Cfg.JWT.MaxAge)).Unix() + claims.Audience = aud + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Minute * time.Duration(cfg.Cfg.JWT.MaxAge))) - // https://godoc.org/github.com/dgrijalva/jwt-go#NewWithClaims - token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims) - log.Debugf("token: %v", token) + // https://github.com/vouch/vouch-proxy/issues/287 + if cfg.Cfg.Headers.AccessToken == "" { + claims.PAccessToken = "" + } + + if cfg.Cfg.Headers.IDToken == "" { + claims.PIdToken = "" + } + // https://godoc.org/github.com/golang-jwt/jwt#NewWithClaims + token := jwt.NewWithClaims(jwt.GetSigningMethod(cfg.Cfg.JWT.SigningMethod), claims) // log.Debugf("token: %v", token) - log.Debugf("token expires: %d", claims.StandardClaims.ExpiresAt) - log.Debugf("diff from now: %d", claims.StandardClaims.ExpiresAt-time.Now().Unix()) + log.Debugf("token created, expires: %d diff from now: %d", claims.RegisteredClaims.ExpiresAt, claims.RegisteredClaims.ExpiresAt.Unix()-time.Now().Unix()) - // token -> string. Only server knows this secret (foobar). - ss, err := token.SignedString([]byte(cfg.Cfg.JWT.Secret)) - // ss, err := token.SignedString([]byte("testing")) + key, err := cfg.SigningKey() + if err != nil { + log.Errorf("%s", err) + } + + ss, err := token.SignedString(key) if ss == "" || err != nil { - log.Errorf("signed token error: %s", err) + return "", fmt.Errorf("new JWT: signed token error: %s", err) } if cfg.Cfg.JWT.Compress { - return compressAndEncodeTokenString(ss) - } - return ss -} - -// TokenIsValid gett better error reporting -func TokenIsValid(token *jwt.Token, err error) bool { - if token.Valid { - return true - } else if ve, ok := err.(*jwt.ValidationError); ok { - if ve.Errors&jwt.ValidationErrorMalformed != 0 { - log.Errorf("token malformed") - } else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 { - // Token is either expired or not active yet - log.Errorf("token expired %s", err) - } else { - log.Errorf("token unknown error") + ss, err = compressAndEncodeTokenString(ss) + if ss == "" || err != nil { + return "", fmt.Errorf("new JWT: compressed token error: %w", err) } - } else { - log.Errorf("token unknown error") } - return false + return ss, nil } +// TODO: is this dead code? // SiteInToken searches does the token contain the site? func SiteInToken(site string, token *jwt.Token) bool { - if claims, ok := token.Claims.(*LassoClaims); ok { + if claims, ok := token.Claims.(*VouchClaims); ok { log.Debugf("site %s claim %v", site, claims) - if SiteInClaims(site, claims) { + if claims.SiteInAudience(site) { return true } } - log.Errorf("site %s not found in token", site) + log.Errorf("site %s not found in token audience", site) return false } // ParseTokenString converts signed token to jwt struct func ParseTokenString(tokenString string) (*jwt.Token, error) { - log.Debugf("tokenString %s", tokenString) + log.Debugf("tokenString length: %d", len(tokenString)) if cfg.Cfg.JWT.Compress { tokenString = decodeAndDecompressTokenString(tokenString) - log.Debugf("decompressed tokenString %s", tokenString) + log.Debugf("decompressed tokenString length %d", len(tokenString)) } - return jwt.ParseWithClaims(tokenString, &LassoClaims{}, func(token *jwt.Token) (interface{}, error) { - // return jwt.ParseWithClaims(tokenString, &LassoClaims{}, func(token *jwt.Token) (interface{}, error) { - if token.Method != jwt.GetSigningMethod("HS256") { - return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + key, err := cfg.DecryptionKey() + if err != nil { + log.Errorf("%s", err) + } + + return jwt.ParseWithClaims(tokenString, &VouchClaims{}, func(token *jwt.Token) (interface{}, error) { + // return jwt.ParseWithClaims(tokenString, &VouchClaims{}, func(token *jwt.Token) (interface{}, error) { + if token.Method != jwt.GetSigningMethod(cfg.Cfg.JWT.SigningMethod) { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte(cfg.Cfg.JWT.Secret), nil + return key, nil }) } -// SiteInClaims does the claim contain the value? -func SiteInClaims(site string, claims *LassoClaims) bool { - for _, s := range claims.Sites { - if strings.Contains(site, s) { - log.Debugf("evaluating %s contains %s", site, s) - return true +// SiteInAudience does the claim contain the value? +func (claims *VouchClaims) SiteInAudience(s string) bool { + log.Debugf("claims.Audience: %+v", claims.Audience) + for _, a := range claims.Audience { + // jwt/v4 serialized the audience as a comma-separated string; + // jwt/v5 deserializes that into a single-element []string. + for _, d := range strings.Split(a, comma) { + if s == d || strings.HasSuffix(s, "."+d) { + log.Debugf("site %s is found for claims.Audience %s", s, d) + return true + } } } return false } -// TODO HERE there's something wrong with claims parsing, probably related to LassoClaims not being a pointer // PTokenClaims get all the claims -func PTokenClaims(ptoken *jwt.Token) (LassoClaims, error) { - // func PTokenClaims(ptoken *jwt.Token) (LassoClaims, error) { - // return ptoken.Claims, nil - - // return ptoken.Claims.(*LassoClaims), nil - ptokenClaims, ok := ptoken.Claims.(*LassoClaims) +func PTokenClaims(ptoken *jwt.Token) (*VouchClaims, error) { + ptokenClaims, ok := ptoken.Claims.(*VouchClaims) if !ok { log.Debugf("failed claims: %v %v", ptokenClaims, ptoken.Claims) - return *ptokenClaims, errors.New("cannot parse claims") + return ptokenClaims, errors.New("cannot parse claims") } log.Debugf("*ptokenCLaims: %v", *ptokenClaims) - return *ptokenClaims, nil -} - -// PTokenToEmail returns the Email in the validated ptoken -func PTokenToEmail(ptoken *jwt.Token) (string, error) { - return ptoken.Claims.(*LassoClaims).Email, nil - - // var ptokenClaims LassoClaims - // ptokenClaims, err := PTokenClaims(ptoken) - // if err != nil { - // log.Error(err) - // return "", err - // } - // return ptokenClaims.Email, nil + return ptokenClaims, nil } func decodeAndDecompressTokenString(encgzipss string) string { - var gzipss []byte // gzipss, err := url.QueryUnescape(encgzipss) gzipss, err := base64.URLEncoding.DecodeString(encgzipss) if err != nil { - log.Fatal(err) + log.Debugf("Error in Base64decode: %v", err) } breader := bytes.NewReader(gzipss) zr, err := gzip.NewReader(breader) if err != nil { - log.Fatal(err) + log.Debugf("Error reading gzip data: %v", err) + return "" } if err := zr.Close(); err != nil { - log.Fatal(err) + log.Debugf("Error decoding token: %v", err) } - ss, _ := ioutil.ReadAll(zr) + ss, _ := io.ReadAll(zr) return string(ss) } -func compressAndEncodeTokenString(ss string) string { +func compressAndEncodeTokenString(ss string) (string, error) { var buf bytes.Buffer zw := gzip.NewWriter(&buf) if _, err := zw.Write([]byte(ss)); err != nil { - log.Fatal(err) + return "", err } if err := zw.Close(); err != nil { - log.Fatal(err) + return "", err } ret := base64.URLEncoding.EncodeToString(buf.Bytes()) // ret := url.QueryEscape(buf.String()) - log.Debugf("compressed string: %s", ret) - return ret + log.Debugf("token compressed: was %d bytes, now %d", len(ss), len(ret)) + return ret, nil +} + +// FindJWT look for JWT in Cookie, JWT Header, Authorization Header (OAuth2 Bearer Token) +// and Query String in that order +func FindJWT(r *http.Request) string { + jwt, err := cookie.Cookie(r) + if err == nil { + logger.Debug("jwt found in cookie") + return jwt + } + jwt = r.Header.Get(cfg.Cfg.Headers.JWT) + if jwt != "" { + log.Debugf("jwt from header %s: %s", cfg.Cfg.Headers.JWT, jwt) + return jwt + } + auth := r.Header.Get("Authorization") + if auth != "" { + s := strings.SplitN(auth, " ", 2) + if len(s) == 2 { + jwt = s[1] + log.Debugf("jwt from authorization header: %s", jwt) + return jwt + } + } + jwt = r.URL.Query().Get(cfg.Cfg.Headers.QueryString) + if jwt != "" { + log.Debugf("jwt from querystring %s: %s", cfg.Cfg.Headers.QueryString, jwt) + return jwt + } + return "" +} + +// ClaimsFromJWT parse the jwt and return the claims +func ClaimsFromJWT(jwt string) (*VouchClaims, error) { + var claims *VouchClaims + + jwtParsed, err := ParseTokenString(jwt) + if err != nil { + return nil, err + } + + claims, err = PTokenClaims(jwtParsed) + if err != nil { + // claims = jwtmanager.PTokenClaims(jwtParsed) + // if claims == &jwtmanager.VouchClaims{} { + return nil, err + } + return claims, nil } diff --git a/pkg/jwtmanager/jwtmanager_test.go b/pkg/jwtmanager/jwtmanager_test.go index ed08027a..654723a1 100644 --- a/pkg/jwtmanager/jwtmanager_test.go +++ b/pkg/jwtmanager/jwtmanager_test.go @@ -1,54 +1,97 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package jwtmanager import ( + "encoding/json" + "os" + "path/filepath" "testing" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/structs" - // log "github.com/Sirupsen/logrus" - log "github.com/Sirupsen/logrus" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" ) var ( u1 = structs.User{ - Email: "test@testing.com", - EmailVerified: true, - Name: "Test Name", + Username: "test@testing.com", + Name: "Test Name", + } + t1 = structs.PTokens{ + PAccessToken: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjRvaXU4In0.eyJzdWIiOiJuZnlmZSIsImF1ZCI6ImltX29pY19jbGllbnQiLCJqdGkiOiJUOU4xUklkRkVzUE45enU3ZWw2eng2IiwiaXNzIjoiaHR0cHM6XC9cL3Nzby5tZXljbG91ZC5uZXQ6OTAzMSIsImlhdCI6MTM5MzczNzA3MSwiZXhwIjoxMzkzNzM3MzcxLCJub25jZSI6ImNiYTU2NjY2LTRiMTItNDU2YS04NDA3LTNkMzAyM2ZhMTAwMiIsImF0X2hhc2giOiJrdHFvZVBhc2praVY5b2Z0X3o5NnJBIn0.g1Jc9DohWFfFG3ppWfvW16ib6YBaONC5VMs8J61i5j5QLieY-mBEeVi1D3vr5IFWCfivY4hZcHtoJHgZk1qCumkAMDymsLGX-IGA7yFU8LOjUdR4IlCPlZxZ_vhqr_0gQ9pCFKDkiOv1LVv5x3YgAdhHhpZhxK6rWxojg2RddzvZ9Xi5u2V1UZ0jukwyG2d4PRzDn7WoRNDGwYOEt4qY7lv_NO2TY2eAklP-xYBWu0b9FBElapnstqbZgAXdndNs-Wqp4gyQG5D0owLzxPErR9MnpQfgNcai-PlWI_UrvoopKNbX0ai2zfkuQ-qh6Xn8zgkiaYDHzq4gzwRfwazaqA", + PIdToken: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjRvaXU4In0.eyJzdWIiOiJuZnlmZSIsImF1ZCI6ImltX29pY19jbGllbnQiLCJqdGkiOiJUOU4xUklkRkVzUE45enU3ZWw2eng2IiwiaXNzIjoiaHR0cHM6XC9cL3Nzby5tZXljbG91ZC5uZXQ6OTAzMSIsImlhdCI6MTM5MzczNzA3MSwiZXhwIjoxMzkzNzM3MzcxLCJub25jZSI6ImNiYTU2NjY2LTRiMTItNDU2YS04NDA3LTNkMzAyM2ZhMTAwMiIsImF0X2hhc2giOiJrdHFvZVBhc2praVY5b2Z0X3o5NnJBIn0.g1Jc9DohWFfFG3ppWfvW16ib6YBaONC5VMs8J61i5j5QLieY-mBEeVi1D3vr5IFWCfivY4hZcHtoJHgZk1qCumkAMDymsLGX-IGA7yFU8LOjUdR4IlCPlZxZ_vhqr_0gQ9pCFKDkiOv1LVv5x3YgAdhHhpZhxK6rWxojg2RddzvZ9Xi5u2V1UZ0jukwyG2d4PRzDn7WoRNDGwYOEt4qY7lv_NO2TY2eAklP-xYBWu0b9FBElapnstqbZgAXdndNs-Wqp4gyQG5D0owLzxPErR9MnpQfgNcai-PlWI_UrvoopKNbX0ai2zfkuQ-qh6Xn8zgkiaYDHzq4gzwRfwazaqA", } - lc LassoClaims + lc VouchClaims + + claimjson = `{ + "sub": "f:a95afe53-60ba-4ac6-af15-fab870e72f3d:mrtester", + "groups": ["Website Users", "Test Group"], + "given_name": "Mister", + "family_name": "Tester", + "email": "mrtester@test.int" + }` + customClaims = structs.CustomClaims{} ) func init() { - // log.SetLevel(log.DebugLevel) + cfg.InitForTestPurposes() + Configure() - lc = LassoClaims{ - u1.Email, - Sites, - StandardClaims, + lc = VouchClaims{ + u1.Username, + customClaims.Claims, + t1.PAccessToken, + t1.PIdToken, + RegisteredClaims, } } -func TestCreateUserTokenStringAndParseToEmail(t *testing.T) { +func setUp(t *testing.T, cfgFile string) { + rootDir := os.Getenv(cfg.Branding.UCName + "_ROOT") + if err := os.Setenv(cfg.Branding.UCName+"_CONFIG", filepath.Join(rootDir, "config/testing", cfgFile)); err != nil { + t.Errorf("failed setting environment variable %s_CONFIG", cfg.Branding.UCName) + } + Configure() + cfg.InitForTestPurposes() +} - uts := CreateUserTokenString(u1) - assert.NotEmpty(t, uts) +func TestClaimsHMAC(t *testing.T) { + for _, cfgFile := range []string{"test_config.yml", "test_config_rsa.yml"} { + setUp(t, cfgFile) + json.Unmarshal([]byte(claimjson), &customClaims.Claims) - utsParsed, err := ParseTokenString(uts) - if utsParsed == nil || err != nil { - t.Error(err) - } else { - log.Debugf("test parsed token string %v", utsParsed) - ptemail, _ := PTokenToEmail(utsParsed) - assert.Equal(t, u1.Email, ptemail) - } + log.Debugf("jwt config %s %d", string(cfg.Cfg.JWT.Secret), cfg.Cfg.JWT.MaxAge) + assert.NotEmpty(t, cfg.Cfg.JWT.SigningMethod) + assert.NotEmpty(t, cfg.Cfg.JWT.MaxAge) + + uts, err := NewVPJWT(u1, customClaims, t1) + assert.NoError(t, err) + + utsParsed, err := ParseTokenString(uts) + assert.NoError(t, err) + log.Infof("utsParsed: %+v", utsParsed) + // log.Infof("Sites: %+v", Sites) + assert.True(t, SiteInToken(cfg.Cfg.Domains[0], utsParsed)) + } + json.Unmarshal([]byte(claimjson), &customClaims.Claims) } func TestClaims(t *testing.T) { - cfg.ParseConfig() - + setUp(t, "test_config.yml") + aud = audience() log.Debugf("jwt config %s %d", string(cfg.Cfg.JWT.Secret), cfg.Cfg.JWT.MaxAge) assert.NotEmpty(t, cfg.Cfg.JWT.Secret) assert.NotEmpty(t, cfg.Cfg.JWT.MaxAge) @@ -58,8 +101,71 @@ func TestClaims(t *testing.T) { // log.Infof("lc d %s", d.String()) // lc.StandardClaims.ExpiresAt = now.Add(time.Duration(ExpiresAtMinutes) * time.Minute).Unix() // log.Infof("lc expiresAt %d", now.Unix()-lc.StandardClaims.ExpiresAt) - uts := CreateUserTokenString(u1) + uts, err := NewVPJWT(u1, customClaims, t1) + assert.NoError(t, err) utsParsed, _ := ParseTokenString(uts) - assert.True(t, SiteInToken("naga.bnf.net", utsParsed)) + log.Infof("utsParsed: %+v", utsParsed) + log.Infof("Audience: %+v", aud) + assert.True(t, SiteInToken(cfg.Cfg.Domains[0], utsParsed)) +} +func TestVouchClaims_SiteInAudience(t *testing.T) { + tests := []struct { + name string + cfgFile string + s string + want bool + }{ + {"exact match", "test_config_oauth_claims.yml", "vouch.github.io", true}, + {"subdomain match", "test_config_oauth_claims.yml", "sub.vouch.github.io", true}, + {"suffix attack", "test_config_oauth_claims.yml", "evilvouch.github.io", false}, + {"attacker parent domain", "test_config_oauth_claims.yml", "vouch.github.io.attacker.com", false}, + {"attacker combined", "test_config_oauth_claims.yml", "evilvouch.github.io.attacker.com", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(t, tt.cfgFile) + jwt, err := NewVPJWT(u1, customClaims, t1) + assert.NoError(t, err) + claims, err := ClaimsFromJWT(jwt) + if err != nil { + t.Fatalf("could not construct receiver type: %v", err) + } + got := claims.SiteInAudience(tt.s) + if got != tt.want { + t.Errorf("SiteInAudience(%q) = %v, want %v", tt.s, got, tt.want) + } + }) + } +} + +func TestSiteInAudienceLegacyFormat(t *testing.T) { + claims := &VouchClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"vouch.github.io,other.test"}, + }, + } + + tests := []struct { + name string + s string + want bool + }{ + {"exact first", "vouch.github.io", true}, + {"exact second", "other.test", true}, + {"subdomain first", "sub.vouch.github.io", true}, + {"subdomain second", "sub.other.test", true}, + {"suffix attack first", "evilvouch.github.io", false}, + {"suffix attack second", "evilother.test", false}, + {"attacker parent", "vouch.github.io.attacker.com", false}, + {"unrelated", "evil.com", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := claims.SiteInAudience(tt.s) + if got != tt.want { + t.Errorf("SiteInAudience(%q) = %v, want %v", tt.s, got, tt.want) + } + }) + } } diff --git a/pkg/model/model.go b/pkg/model/model.go deleted file mode 100644 index 83b74110..00000000 --- a/pkg/model/model.go +++ /dev/null @@ -1,61 +0,0 @@ -package model - -// modeled after -// https://www.opsdash.com/blog/persistent-key-value-store-golang.html - -import ( - "errors" - "os" - "time" - - "github.com/bnfinet/lasso/pkg/cfg" - log "github.com/Sirupsen/logrus" - "github.com/boltdb/bolt" -) - -var ( - // ErrNotFound is returned when the key supplied to a Get or Delete - // method does not exist in the database. - ErrNotFound = errors.New("key not found") - - // ErrBadValue is returned when the value supplied to the Put method - // is nil. - ErrBadValue = errors.New("bad value") - - //Db holds the db - Db *bolt.DB - - userBucket = []byte("users") - teamBucket = []byte("teams") - siteBucket = []byte("sites") -) - -// may want to use encode/gob to store the user record -func init() { - Db, _ = Open(os.Getenv("LASSO_ROOT") + cfg.Cfg.DB.File) -} - -// Open the boltdb -func Open(dbfile string) (*bolt.DB, error) { - - opts := &bolt.Options{ - Timeout: 50 * time.Millisecond, - } - - db, err := bolt.Open(dbfile, 0644, opts) - if err != nil { - log.Fatal(err) - return nil, err - } - return db, nil - -} - -func getBucket(tx *bolt.Tx, key []byte) *bolt.Bucket { - b, err := tx.CreateBucketIfNotExists(key) - if err != nil { - log.Errorf("could not create bucket %s", err) - return nil - } - return b -} diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go deleted file mode 100644 index 663ffa4a..00000000 --- a/pkg/model/model_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package model - -// modeled after -// https://www.opsdash.com/blog/persistent-key-value-store-golang.html - -import ( - "os" - "testing" - - log "github.com/Sirupsen/logrus" - - "github.com/stretchr/testify/assert" - - "github.com/bnfinet/lasso/pkg/structs" -) - -var testdb = "/tmp/storage-test.db" - -func init() { - Db, _ = Open(testdb) - - log.SetLevel(log.DebugLevel) -} - -func TestPutUserGetUser(t *testing.T) { - os.Remove(testdb) - Open(testdb) - - u1 := structs.User{ - Email: "test@testing.com", - Name: "Test Name", - } - u2 := &structs.User{} - u3 := structs.User{ - Email: "testagain@testing.com", - Name: "Test Again", - } - - if err := PutUser(u1); err != nil { - log.Error(err) - } - User([]byte(u1.Email), u2) - if err := PutUser(u3); err != nil { - log.Error(err) - } - log.Debugf("user retrieved: %v", *u2) - assert.Equal(t, u1.Email, u2.Email) - - if err := PutUser(u3); err != nil { - log.Error(err) - } - var users []structs.User - if err := AllUsers(&users); err != nil { - log.Error(err) - } - assert.Len(t, users, 2) -} - -func TestPutSiteGetSite(t *testing.T) { - os.Remove(testdb) - Open(testdb) - - s1 := structs.Site{Domain: "test.bnf.net"} - s2 := &structs.Site{} - - if err := PutSite(s1); err != nil { - log.Error(err) - } - Site([]byte(s1.Domain), s2) - log.Debugf("site retrieved: %v", *s2) - assert.Equal(t, s1.Domain, s2.Domain) -} - -func TestPutTeamGetTeam(t *testing.T) { - os.Remove(testdb) - Open(testdb) - - t1 := structs.Team{Name: "testname"} - t2 := &structs.Team{} - - if err := PutTeam(t1); err != nil { - log.Error(err) - } - Team([]byte(t1.Name), t2) - log.Debugf("team retrieved: %v", *t2) - assert.Equal(t, t1.Name, t2.Name) -} diff --git a/pkg/model/site.go b/pkg/model/site.go deleted file mode 100644 index 9b43da6c..00000000 --- a/pkg/model/site.go +++ /dev/null @@ -1,105 +0,0 @@ -package model - -import ( - "bytes" - "encoding/gob" - "time" - - log "github.com/Sirupsen/logrus" - "github.com/bnfinet/lasso/pkg/structs" - "github.com/boltdb/bolt" -) - -// PutSite inna da db -func PutSite(s structs.Site) error { - siteexists := false - curs := &structs.Site{} - err := Site([]byte(s.Domain), curs) - if err != nil { - log.Error(err) - } else { - siteexists = true - } - - return Db.Update(func(tx *bolt.Tx) error { - b := getBucket(tx, siteBucket) - - s.LastUpdate = time.Now().Unix() - if siteexists { - log.Debugf("siteexists.. keeping time at %v", curs.CreatedOn) - s.CreatedOn = curs.CreatedOn - } else { - id, _ := b.NextSequence() - s.ID = int(id) - s.CreatedOn = s.LastUpdate - } - - eS, err := gobEncodeSite(&s) - if err != nil { - log.Error(err) - return err - } - - err = b.Put([]byte(s.Domain), eS) - if err != nil { - return err - } - return nil - }) -} - -// Site lookup user from key -func Site(key []byte, s *structs.Site) error { - return Db.View(func(tx *bolt.Tx) error { - if b := tx.Bucket(siteBucket); b != nil { - val := b.Get([]byte(key)) - site, err := gobDecodeSite(val) - if err != nil { - return err - } - *s = *site - log.Debugf("site key %s val %v", key, s) - log.Debugf("retrieved %s from db", s.Domain) - } - return nil - }) -} - -// AllSites collect all items -func AllSites(sites *[]structs.Site) error { - return Db.View(func(tx *bolt.Tx) error { - if b := tx.Bucket(siteBucket); b != nil { - // c := b.Cursor() - b.ForEach(func(k, v []byte) error { - log.Debugf("key=%s, value=%s\n", k, v) - s := structs.Site{} - Site(k, &s) - *sites = append(*sites, s) - return nil - }) - log.Debugf("sites %v", sites) - } - return nil - }) -} - -func gobEncodeSite(s *structs.Site) ([]byte, error) { - buf := new(bytes.Buffer) - enc := gob.NewEncoder(buf) - err := enc.Encode(s) - if err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func gobDecodeSite(data []byte) (*structs.Site, error) { - s := &structs.Site{} - buf := bytes.NewBuffer(data) - dec := gob.NewDecoder(buf) - err := dec.Decode(s) - if err != nil { - return nil, err - } - return s, nil -} diff --git a/pkg/model/team.go b/pkg/model/team.go deleted file mode 100644 index 1a378633..00000000 --- a/pkg/model/team.go +++ /dev/null @@ -1,106 +0,0 @@ -package model - -import ( - "bytes" - "encoding/gob" - "fmt" - "time" - - log "github.com/Sirupsen/logrus" - "github.com/bnfinet/lasso/pkg/structs" - "github.com/boltdb/bolt" -) - -// PutTeam inna da db -func PutTeam(t structs.Team) error { - teamexists := false - curt := &structs.Team{} - err := Team([]byte(t.Name), curt) - if err == nil { - teamexists = true - } else { - log.Error(err) - } - - return Db.Update(func(tx *bolt.Tx) error { - if b := getBucket(tx, teamBucket); b != nil { - t.LastUpdate = time.Now().Unix() - if teamexists { - log.Debugf("teamexists.. keeping time at %v", curt.CreatedOn) - t.CreatedOn = curt.CreatedOn - } else { - id, _ := b.NextSequence() - t.ID = int(id) - t.CreatedOn = t.LastUpdate - } - - eT, err := gobEncodeTeam(&t) - if err != nil { - log.Error(err) - return err - } - - err = b.Put([]byte(t.Name), eT) - if err != nil { - return err - } - } - return nil - }) -} - -// Team lookup team from key -func Team(key []byte, t *structs.Team) error { - return Db.View(func(tx *bolt.Tx) error { - if b := tx.Bucket(teamBucket); b != nil { - val := b.Get([]byte(key)) - team, err := gobDecodeTeam(val) - if err != nil { - return err - } - *t = *team - log.Debugf("retrieved %s from db", t.Name) - return nil - } - return fmt.Errorf("no bucket for %s", teamBucket) - }) -} - -// AllTeams collect all items -func AllTeams(teams *[]structs.Team) error { - return Db.View(func(tx *bolt.Tx) error { - if b := tx.Bucket(teamBucket); b != nil { - b.ForEach(func(k, v []byte) error { - log.Debugf("key=%s, value=%s\n", k, v) - t := structs.Team{} - Team(k, &t) - *teams = append(*teams, t) - return nil - }) - log.Debugf("teams %v", teams) - return nil - } - return fmt.Errorf("no bucket for %s", teamBucket) - }) -} - -func gobEncodeTeam(t *structs.Team) ([]byte, error) { - buf := new(bytes.Buffer) - enc := gob.NewEncoder(buf) - err := enc.Encode(t) - if err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func gobDecodeTeam(data []byte) (*structs.Team, error) { - t := &structs.Team{} - buf := bytes.NewBuffer(data) - dec := gob.NewDecoder(buf) - err := dec.Decode(t) - if err != nil { - return nil, err - } - return t, nil -} diff --git a/pkg/model/user.go b/pkg/model/user.go deleted file mode 100644 index 3ed3730f..00000000 --- a/pkg/model/user.go +++ /dev/null @@ -1,110 +0,0 @@ -package model - -import ( - "bytes" - "encoding/gob" - "fmt" - "time" - - log "github.com/Sirupsen/logrus" - "github.com/bnfinet/lasso/pkg/structs" - "github.com/boltdb/bolt" -) - -// PutUser inna da db -func PutUser(u structs.User) error { - userexists := false - curu := &structs.User{} - err := User([]byte(u.Email), curu) - if err == nil { - userexists = true - } else { - log.Error(err) - } - - return Db.Update(func(tx *bolt.Tx) error { - b := getBucket(tx, userBucket) - - u.LastUpdate = time.Now().Unix() - if userexists { - log.Debugf("userexists.. keeping time at %v", curu.CreatedOn) - u.CreatedOn = curu.CreatedOn - } else { - u.CreatedOn = u.LastUpdate - id, _ := b.NextSequence() - u.ID = int(id) - log.Debugf("new user.. setting created on to %v", u.CreatedOn) - } - - eU, err := gobEncodeUser(&u) - if err != nil { - log.Error(err) - return err - } - - err = b.Put([]byte(u.Email), eU) - if err != nil { - log.Error(err) - return err - } - log.Debugf("user created %v", u) - return nil - }) -} - -// User lookup user from key -func User(key []byte, u *structs.User) error { - return Db.View(func(tx *bolt.Tx) error { - if b := tx.Bucket(userBucket); b != nil { - log.Debugf("key is %s", key) - val := b.Get([]byte(key)) - user, err := gobDecodeUser(val) - if err != nil { - return err - } - *u = *user - log.Debugf("retrieved %s from db", u.Email) - return nil - } - return fmt.Errorf("no bucket for %s", userBucket) - }) -} - -// AllUsers collect all items -func AllUsers(users *[]structs.User) error { - return Db.View(func(tx *bolt.Tx) error { - if b := tx.Bucket(userBucket); b != nil { - b.ForEach(func(k, v []byte) error { - log.Debugf("key=%s, value=%s\n", k, v) - u := structs.User{} - User(k, &u) - *users = append(*users, u) - return nil - }) - log.Debugf("users %v", users) - return nil - } - return fmt.Errorf("no bucket for %s", userBucket) - }) -} - -func gobEncodeUser(u *structs.User) ([]byte, error) { - buf := new(bytes.Buffer) - enc := gob.NewEncoder(buf) - err := enc.Encode(u) - if err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func gobDecodeUser(data []byte) (*structs.User, error) { - u := &structs.User{} - buf := bytes.NewBuffer(data) - dec := gob.NewDecoder(buf) - err := dec.Decode(u) - if err != nil { - return nil, err - } - return u, nil -} diff --git a/pkg/providers/adfs/adfs.go b/pkg/providers/adfs/adfs.go new file mode 100644 index 00000000..ed2d7da2 --- /dev/null +++ b/pkg/providers/adfs/adfs.go @@ -0,0 +1,128 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package adfs + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "go.uber.org/zap" + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" +) + +// Provider provider specific functions +type Provider struct{} + +type adfsTokenRes struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` // relative seconds from now +} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +// More info: https://docs.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-scenarios-for-developers#supported-scenarios +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + code := r.URL.Query().Get("code") + log.Debugf("code: %s", code) + + formData := url.Values{} + formData.Set("code", code) + formData.Set("grant_type", "authorization_code") + formData.Set("resource", cfg.GenOAuth.RelyingPartyId) + formData.Set("client_id", cfg.GenOAuth.ClientID) + formData.Set("redirect_uri", cfg.GenOAuth.RedirectURL) + if cfg.GenOAuth.ClientSecret != "" { + formData.Set("client_secret", cfg.GenOAuth.ClientSecret) + } + req, err := http.NewRequest("POST", cfg.GenOAuth.TokenURL, strings.NewReader(formData.Encode())) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(formData.Encode()))) + req.Header.Set("Accept", "application/json") + + client := &http.Client{} + userinfo, err := client.Do(req) + + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + + data, _ := io.ReadAll(userinfo.Body) + tokenRes := adfsTokenRes{} + + if err := json.Unmarshal(data, &tokenRes); err != nil { + return fmt.Errorf("getUserInfoFromADFS oauth2: cannot fetch token: %+v", err) + } + + ptokens.PAccessToken = string(tokenRes.AccessToken) + ptokens.PIdToken = string(tokenRes.IDToken) + + s := strings.Split(tokenRes.IDToken, ".") + if len(s) < 2 { + return fmt.Errorf("getUserInfoFromADFS jws: invalid token received") + } + + idToken, err := base64.RawURLEncoding.DecodeString(s[1]) + if err != nil { + return fmt.Errorf("getUserInfoFromADFS decode token: %+v", err) + } + log.Debugf("getUserInfoFromADFS idToken: %+v", string(idToken)) + + adfsUser := structs.ADFSUser{} + json.Unmarshal([]byte(idToken), &adfsUser) + log.Infof("adfs adfsUser: %+v", adfsUser) + // data contains an access token, refresh token, and id token + // Please note that in order for custom claims to work you MUST set allatclaims in ADFS to be passed + // https://oktotechnologies.ca/2018/08/26/adfs-openidconnect-configuration/ + if err = common.MapClaims([]byte(idToken), customClaims); err != nil { + return err + } + adfsUser.PrepareUserData() + var rxEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + + if len(adfsUser.Email) == 0 { + // If the email is blank, we will try to determine if the UPN is an email. + if rxEmail.MatchString(adfsUser.UPN) { + // Set the email from UPN if there is a valid email present. + adfsUser.Email = adfsUser.UPN + } + } + user.Username = adfsUser.Username + user.Email = adfsUser.Email + log.Debugf("User Obj: %+v", user) + return nil +} diff --git a/pkg/providers/alibaba/alibaba.go b/pkg/providers/alibaba/alibaba.go new file mode 100644 index 00000000..6503ee3e --- /dev/null +++ b/pkg/providers/alibaba/alibaba.go @@ -0,0 +1,66 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package alibaba + +import ( + "encoding/json" + "io" + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, true) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := io.ReadAll(userinfo.Body) + log.Infof("Alibaba userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + aliUser := structs.AlibabaUser{} + if err = json.Unmarshal(data, &aliUser); err != nil { + log.Error(err) + return err + } + aliUser.PrepareUserData() + user.Username = aliUser.Username + user.Email = aliUser.Email + return nil +} diff --git a/pkg/providers/azure/azure.go b/pkg/providers/azure/azure.go new file mode 100644 index 00000000..b9387802 --- /dev/null +++ b/pkg/providers/azure/azure.go @@ -0,0 +1,95 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package azure + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + _, _, err := common.PrepareTokensAndClient(r, ptokens, true, opts...) + if err != nil { + return err + } + + // For Azure AD, there is very little information in the /userinfo response. + // Since we can get everything we currently need from the access token, we are + // just going to extract user info and custom claims from there. + azureUser := structs.AzureUser{} + + var tokenParts []string + + if cfg.GenOAuth.AzureToken == "access_token" { + tokenParts = strings.Split(ptokens.PAccessToken, ".") + } else if cfg.GenOAuth.AzureToken == "id_token" { + tokenParts = strings.Split(ptokens.PIdToken, ".") + } else { + err = fmt.Errorf("azure Token not access_token or id_token") + log.Error(err) + return err + } + + if len(tokenParts) < 2 { + err = fmt.Errorf("azure GetUserInfo: invalid token received; not enough parts") + log.Error(err) + return err + } + + tokenBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) + if err != nil { + err = fmt.Errorf("azure GetUserInfo: decoding token failed: %+v", err) + log.Error(err) + return err + } + + if err = common.MapClaims(tokenBytes, customClaims); err != nil { + log.Error(err) + return err + } + + log.Debugf("azure GetUserInfo: getting user info from token: %+v", string(tokenBytes)) + if err = json.Unmarshal(tokenBytes, &azureUser); err != nil { + err = fmt.Errorf("azure getUserInfoFromTokens: unpacking token into AzureUser failed: %+v", err) + log.Error(err) + return err + } + + azureUser.PrepareUserData() + + user.Username = azureUser.Username + user.Name = azureUser.Name + user.Email = azureUser.Email + log.Infof("azure GetUserInfo: User: %+v", user) + + return nil +} diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go new file mode 100644 index 00000000..01b82e08 --- /dev/null +++ b/pkg/providers/common/common.go @@ -0,0 +1,77 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package common + +import ( + "context" + "encoding/json" + "net/http" + + "go.uber.org/zap" + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" +) + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger +} + +// PrepareTokensAndClient setup the client, usually for a UserInfo request +func PrepareTokensAndClient(r *http.Request, ptokens *structs.PTokens, setProviderToken bool, opts ...oauth2.AuthCodeOption) (*http.Client, *oauth2.Token, error) { + providerToken, err := cfg.OAuthClient.Exchange(context.TODO(), r.URL.Query().Get("code"), opts...) + if err != nil { + return nil, nil, err + } + ptokens.PAccessToken = providerToken.AccessToken + + if setProviderToken { + if providerToken.Extra("id_token") != nil { + // Certain providers (eg. gitea) don't provide an id_token + // and it's not necessary for the authentication phase + ptokens.PIdToken = providerToken.Extra("id_token").(string) + } else { + log.Debugf("id_token missing - may not be supported by this provider") + } + } + + log.Debugf("ptokens: accessToken length: %d, IdToken length: %d", len(ptokens.PAccessToken), len(ptokens.PIdToken)) + client := cfg.OAuthClient.Client(context.TODO(), providerToken) + return client, providerToken, err +} + +// MapClaims populate CustomClaims from userInfo for each configure claims header +func MapClaims(claims []byte, customClaims *structs.CustomClaims) error { + var f interface{} + err := json.Unmarshal(claims, &f) + if err != nil { + log.Error("Error unmarshaling claims") + return err + } + m := f.(map[string]interface{}) + for k := range m { + var found = false + for claim := range cfg.Cfg.Headers.ClaimsCleaned { + if k == claim { + found = true + } + } + if !found { + delete(m, k) + } + } + customClaims.Claims = m + return nil +} diff --git a/pkg/providers/discord/discord.go b/pkg/providers/discord/discord.go new file mode 100644 index 00000000..8221d0a8 --- /dev/null +++ b/pkg/providers/discord/discord.go @@ -0,0 +1,85 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package discord + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "golang.org/x/oauth2" + + "go.uber.org/zap" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, true, opts...) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, err := io.ReadAll(userinfo.Body) + if err != nil { + return err + } + log.Infof("Discord userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + dUser := structs.DiscordUser{} + if err = json.Unmarshal(data, &dUser); err != nil { + log.Error(err) + return err + } + + // If the provider is configured to use IDs, the ID is copied to PreparedUsername. + if cfg.GenOAuth.DiscordUseIDs { + user.Username = dUser.Id + } else { + user.Username = dUser.Username + + // If the Discriminator is present that is appended to the Username in the format "Username#Discriminator" + // to match the old format of Discord usernames + // Previous format which is being phased out: https://support.discord.com/hc/en-us/articles/4407571667351-Law-Enforcement-Guidelines Subheading "How to find usernames and discriminators" + // Details about the new username requirements: https://support.discord.com/hc/en-us/articles/12620128861463 + if dUser.Discriminator != "0" { + user.Username = fmt.Sprintf("%s#%s", dUser.Username, dUser.Discriminator) + } + } + user.Email = dUser.Email + + return nil +} diff --git a/pkg/providers/github/github.go b/pkg/providers/github/github.go new file mode 100644 index 00000000..51a0f77e --- /dev/null +++ b/pkg/providers/github/github.go @@ -0,0 +1,180 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package github + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +// Provider provider specific functions +type Provider struct { + PrepareTokensAndClient func(r *http.Request, ptokens *structs.PTokens, setProviderToken bool, opts ...oauth2.AuthCodeOption) (*http.Client, *oauth2.Token, error) +} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo github user info, calls github api for org and teams +func (me Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := me.PrepareTokensAndClient(r, ptokens, true, opts...) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := io.ReadAll(userinfo.Body) + log.Infof("github userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + ghUser := structs.GitHubUser{} + if err = json.Unmarshal(data, &ghUser); err != nil { + log.Error(err) + return err + } + log.Debug("getUserInfoFromGitHub ghUser") + log.Debug(ghUser) + log.Debug("getUserInfoFromGitHub user") + log.Debug(user) + + ghUser.PrepareUserData() + user.Email = ghUser.Email + user.Name = ghUser.Name + user.Username = ghUser.Username + user.ID = ghUser.ID + + // user = &ghUser.User + + toOrgAndTeam := func(orgAndTeam string) (string, string) { + split := strings.Split(orgAndTeam, "/") + if len(split) == 1 { + // only organization given + return orgAndTeam, "" + } else if len(split) == 2 { + return split[0], split[1] + } else { + return "", "" + } + } + + if len(cfg.Cfg.TeamWhiteList) != 0 { + for _, orgAndTeam := range cfg.Cfg.TeamWhiteList { + org, team := toOrgAndTeam(orgAndTeam) + if org != "" { + log.Info(org) + var err error + isMember := false + if team != "" { + isMember, err = getTeamMembershipStateFromGitHub(client, user, org, team) + } else { + isMember, err = getOrgMembershipStateFromGitHub(client, user, org) + } + if err != nil { + return err + } + if isMember { + user.TeamMemberships = append(user.TeamMemberships, orgAndTeam) + } + + } else { + log.Warnf("Invalid org/team format in %s: must be written as /", orgAndTeam) + } + } + } + + log.Debug("getUserInfoFromGitHub") + log.Debug(user) + return nil +} + +func getOrgMembershipStateFromGitHub(client *http.Client, user *structs.User, orgID string) (isMember bool, rerr error) { + replacements := strings.NewReplacer(":org_id", orgID, ":username", user.Username) + orgMembershipResp, err := client.Get(replacements.Replace(cfg.GenOAuth.UserOrgURL)) + if err != nil { + log.Error(err) + return false, err + } + + if orgMembershipResp.StatusCode == 302 { + log.Debug("Need to check public membership") + location := orgMembershipResp.Header.Get("Location") + if location != "" { + orgMembershipResp, err = client.Get(location) + if err != nil { + log.Error(err) + } + } + } + + if orgMembershipResp.StatusCode == 204 { + log.Debug("getOrgMembershipStateFromGitHub isMember: true") + return true, nil + } else if orgMembershipResp.StatusCode == 404 { + log.Debug("getOrgMembershipStateFromGitHub isMember: false") + return false, nil + } else { + log.Errorf("getOrgMembershipStateFromGitHub: unexpected status code %d", orgMembershipResp.StatusCode) + return false, errors.New("Unexpected response status " + orgMembershipResp.Status) + } +} + +func getTeamMembershipStateFromGitHub(client *http.Client, user *structs.User, orgID string, team string) (isMember bool, rerr error) { + replacements := strings.NewReplacer(":org_id", orgID, ":team_slug", team, ":username", user.Username) + membershipStateResp, err := client.Get(replacements.Replace(cfg.GenOAuth.UserTeamURL)) + if err != nil { + log.Error(err) + return false, err + } + defer func() { + if err := membershipStateResp.Body.Close(); err != nil { + rerr = err + } + }() + if membershipStateResp.StatusCode == 200 { + data, _ := io.ReadAll(membershipStateResp.Body) + log.Infof("github team membership body: ", string(data)) + ghTeamState := structs.GitHubTeamMembershipState{} + if err = json.Unmarshal(data, &ghTeamState); err != nil { + log.Error(err) + return false, err + } + log.Debugf("getTeamMembershipStateFromGitHub ghTeamState %s", ghTeamState) + return ghTeamState.State == "active", nil + } else if membershipStateResp.StatusCode == 404 { + log.Debug("getTeamMembershipStateFromGitHub isMember: false") + return false, err + } else { + log.Errorf("getTeamMembershipStateFromGitHub: unexpected status code %d", membershipStateResp.StatusCode) + return false, errors.New("Unexpected response status " + membershipStateResp.Status) + } +} diff --git a/pkg/providers/github/github_test.go b/pkg/providers/github/github_test.go new file mode 100644 index 00000000..d895d354 --- /dev/null +++ b/pkg/providers/github/github_test.go @@ -0,0 +1,199 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package github + +import ( + "encoding/json" + "net/http" + "regexp" + "testing" + + mockhttp "github.com/karupanerura/go-mock-http-response" + "github.com/stretchr/testify/assert" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/domains" + "github.com/vouch/vouch-proxy/pkg/structs" + "golang.org/x/oauth2" +) + +type ReqMatcher func(*http.Request) bool + +type FunResponsePair struct { + matcher ReqMatcher + response *mockhttp.ResponseMock +} + +type Transport struct { + MockError error +} + +func (c *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + if c.MockError != nil { + return nil, c.MockError + } + for _, p := range mockedResponses { + if p.matcher(req) { + requests = append(requests, req.URL.String()) + return p.response.MakeResponse(req), nil + } + } + return nil, nil +} + +func mockResponse(fun ReqMatcher, statusCode int, headers map[string]string, body []byte) { + mockedResponses = append(mockedResponses, FunResponsePair{matcher: fun, response: mockhttp.NewResponseMock(statusCode, headers, body)}) +} + +func regexMatcher(expr string) ReqMatcher { + return func(r *http.Request) bool { + matches, _ := regexp.Match(expr, []byte(r.URL.String())) + return matches + } +} + +func urlEquals(value string) ReqMatcher { + return func(r *http.Request) bool { + return r.URL.String() == value + } +} + +func assertURLCalled(t *testing.T, url string) { + found := false + for _, requestedURL := range requests { + if requestedURL == url { + found = true + break + } + } + assert.True(t, found, "Expected %s to have been called, but got only %s", url, requests) +} + +var ( + user *structs.User + token = &oauth2.Token{AccessToken: "123"} + mockedResponses = []FunResponsePair{} + requests []string + client = &http.Client{Transport: &Transport{}} +) + +func setUp() { + log = cfg.Logging.Logger + cfg.InitForTestPurposesWithProvider("github") + + cfg.Cfg.AllowAllUsers = false + cfg.Cfg.WhiteList = make([]string, 0) + cfg.Cfg.TeamWhiteList = make([]string, 0) + cfg.Cfg.Domains = []string{"domain1"} + + domains.Configure() + + mockedResponses = []FunResponsePair{} + requests = make([]string, 0) + + user = &structs.User{Username: "testuser", Email: "test@example.com"} +} + +func TestGetTeamMembershipStateFromGitHubActive(t *testing.T) { + setUp() + mockResponse(regexMatcher(".*"), http.StatusOK, map[string]string{}, []byte("{\"state\": \"active\"}")) + + isMember, err := getTeamMembershipStateFromGitHub(client, user, "org1", "team1") + + assert.Nil(t, err) + assert.True(t, isMember) +} + +func TestGetTeamMembershipStateFromGitHubInactive(t *testing.T) { + setUp() + mockResponse(regexMatcher(".*"), http.StatusOK, map[string]string{}, []byte("{\"state\": \"inactive\"}")) + + isMember, err := getTeamMembershipStateFromGitHub(client, user, "org1", "team1") + + assert.Nil(t, err) + assert.False(t, isMember) +} + +func TestGetTeamMembershipStateFromGitHubNotAMember(t *testing.T) { + setUp() + mockResponse(regexMatcher(".*"), http.StatusNotFound, map[string]string{}, []byte("")) + + isMember, err := getTeamMembershipStateFromGitHub(client, user, "org1", "team1") + + assert.Nil(t, err) + assert.False(t, isMember) +} + +func TestGetOrgMembershipStateFromGitHubNotFound(t *testing.T) { + setUp() + mockResponse(regexMatcher(".*"), http.StatusNotFound, map[string]string{}, []byte("")) + + isMember, err := getOrgMembershipStateFromGitHub(client, user, "myorg") + + assert.Nil(t, err) + assert.False(t, isMember) + + expectedOrgMembershipURL := "https://api.github.com/orgs/myorg/members/" + user.Username + assertURLCalled(t, expectedOrgMembershipURL) +} + +func TestGetOrgMembershipStateFromGitHubNoOrgAccess(t *testing.T) { + setUp() + location := "https://api.github.com/orgs/myorg/public_members/" + user.Username + + mockResponse(regexMatcher(".*orgs/myorg/members.*"), http.StatusFound, map[string]string{"Location": location}, []byte("")) + mockResponse(regexMatcher(".*orgs/myorg/public_members.*"), http.StatusNoContent, map[string]string{}, []byte("")) + + isMember, err := getOrgMembershipStateFromGitHub(client, user, "myorg") + + assert.Nil(t, err) + assert.True(t, isMember) + + expectedOrgMembershipURL := "https://api.github.com/orgs/myorg/members/" + user.Username + assertURLCalled(t, expectedOrgMembershipURL) + + expectedOrgPublicMembershipURL := "https://api.github.com/orgs/myorg/public_members/" + user.Username + assertURLCalled(t, expectedOrgPublicMembershipURL) +} + +func TestGetUserInfo(t *testing.T) { + setUp() + + userInfoContent, _ := json.Marshal(structs.GitHubUser{ + User: structs.User{ + Username: "test", + CreatedOn: 123, + Email: "email@example.com", + ID: 1, + LastUpdate: 123, + Name: "name", + }, + Login: "myusername", + Picture: "avatar-url", + }) + mockResponse(urlEquals(cfg.GenOAuth.UserInfoURL), http.StatusOK, map[string]string{}, userInfoContent) + + cfg.Cfg.TeamWhiteList = append(cfg.Cfg.TeamWhiteList, "myOtherOrg", "myorg/myteam") + + mockResponse(regexMatcher(".*teams.*"), http.StatusOK, map[string]string{}, []byte("{\"state\": \"active\"}")) + mockResponse(regexMatcher(".*members.*"), http.StatusNoContent, map[string]string{}, []byte("")) + + provider := Provider{PrepareTokensAndClient: func(_ *http.Request, _ *structs.PTokens, _ bool, opts ...oauth2.AuthCodeOption) (*http.Client, *oauth2.Token, error) { + return client, token, nil + }} + err := provider.GetUserInfo(nil, user, &structs.CustomClaims{}, &structs.PTokens{}) + + assert.Nil(t, err) + assert.Equal(t, "myusername", user.Username) + assert.Equal(t, []string{"myOtherOrg", "myorg/myteam"}, user.TeamMemberships) + + expectedTeamMembershipURL := "https://api.github.com/orgs/myorg/teams/myteam/memberships/myusername" + assertURLCalled(t, expectedTeamMembershipURL) +} diff --git a/pkg/providers/google/google.go b/pkg/providers/google/google.go new file mode 100644 index 00000000..35d571db --- /dev/null +++ b/pkg/providers/google/google.go @@ -0,0 +1,64 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package google + +import ( + "encoding/json" + "io" + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, true, opts...) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := io.ReadAll(userinfo.Body) + log.Infof("google userinfo body: ", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + if err = json.Unmarshal(data, user); err != nil { + log.Error(err) + return err + } + user.PrepareUserData() + + return nil +} diff --git a/pkg/providers/homeassistant/homeassistant.go b/pkg/providers/homeassistant/homeassistant.go new file mode 100644 index 00000000..ca6b457c --- /dev/null +++ b/pkg/providers/homeassistant/homeassistant.go @@ -0,0 +1,43 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package homeassistant + +import ( + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" +) + +// Provider provider specific functions +type Provider struct{} + +// var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + // log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +// More info: https://developers.home-assistant.io/docs/en/auth_api.html +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + _, providerToken, err := common.PrepareTokensAndClient(r, ptokens, false, opts...) + if err != nil { + return err + } + ptokens.PAccessToken = providerToken.Extra("access_token").(string) + // Home assistant does not provide an API to query username, so we statically set it to "homeassistant" + user.Username = "homeassistant" + return nil +} diff --git a/pkg/providers/indieauth/indieauth.go b/pkg/providers/indieauth/indieauth.go new file mode 100644 index 00000000..7aa41937 --- /dev/null +++ b/pkg/providers/indieauth/indieauth.go @@ -0,0 +1,109 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package indieauth + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + // indieauth sends the "me" setting in json back to the callback, so just pluck it from the callback + code := r.URL.Query().Get("code") + log.Errorf("ptoken.AccessToken: %s", code) + var b bytes.Buffer + w := multipart.NewWriter(&b) + // v.Set("code", code) + fw, err := w.CreateFormField("code") + if err != nil { + return err + } + if _, err = fw.Write([]byte(code)); err != nil { + return err + } + // v.Set("redirect_uri", cfg.GenOAuth.RedirectURL) + if fw, err = w.CreateFormField("redirect_uri"); err != nil { + return err + } + if _, err = fw.Write([]byte(cfg.GenOAuth.RedirectURL)); err != nil { + return err + } + // v.Set("client_id", cfg.GenOAuth.ClientID) + if fw, err = w.CreateFormField("client_id"); err != nil { + return err + } + if _, err = fw.Write([]byte(cfg.GenOAuth.ClientID)); err != nil { + return err + } + if err = w.Close(); err != nil { + log.Error("error closing writer.") + } + + req, err := http.NewRequest("POST", cfg.GenOAuth.AuthURL, &b) + if err != nil { + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Accept", "application/json") + + // v := url.Values{} + // userinfo, err := client.PostForm(cfg.GenOAuth.UserInfoURL, v) + + client := &http.Client{} + userinfo, err := client.Do(req) + + if err != nil { + // http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + + data, _ := io.ReadAll(userinfo.Body) + log.Infof("indieauth userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + iaUser := structs.IndieAuthUser{} + if err = json.Unmarshal(data, &iaUser); err != nil { + log.Error(err) + return err + } + iaUser.PrepareUserData() + user.Username = iaUser.Username + log.Debug(user) + return nil +} diff --git a/pkg/providers/nextcloud/nextcloud.go b/pkg/providers/nextcloud/nextcloud.go new file mode 100644 index 00000000..b912730d --- /dev/null +++ b/pkg/providers/nextcloud/nextcloud.go @@ -0,0 +1,66 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package nextcloud + +import ( + "encoding/json" + "io" + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, true) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := io.ReadAll(userinfo.Body) + log.Infof("Ocs userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + ncUser := structs.NextcloudUser{} + if err = json.Unmarshal(data, &ncUser); err != nil { + log.Error(err) + return err + } + ncUser.PrepareUserData() + user.Username = ncUser.Username + user.Email = ncUser.Email + return nil +} diff --git a/pkg/providers/openid/openid.go b/pkg/providers/openid/openid.go new file mode 100644 index 00000000..d5c9c5e7 --- /dev/null +++ b/pkg/providers/openid/openid.go @@ -0,0 +1,63 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package openid + +import ( + "encoding/json" + "io" + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, true, opts...) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := io.ReadAll(userinfo.Body) + log.Infof("OpenID userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + if err = json.Unmarshal(data, user); err != nil { + log.Error(err) + return err + } + user.PrepareUserData() + return nil +} diff --git a/pkg/providers/openstax/openstax.go b/pkg/providers/openstax/openstax.go new file mode 100644 index 00000000..6c7f7687 --- /dev/null +++ b/pkg/providers/openstax/openstax.go @@ -0,0 +1,70 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package openstax + +import ( + "encoding/json" + "io" + "net/http" + + "golang.org/x/oauth2" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/providers/common" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// Provider provider specific functions +type Provider struct{} + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func (Provider) Configure() { + log = cfg.Logging.Logger +} + +// GetUserInfo provider specific call to get userinfomation +func (Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens, opts ...oauth2.AuthCodeOption) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, false, opts...) + if err != nil { + return err + } + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL) + if err != nil { + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := io.ReadAll(userinfo.Body) + log.Infof("OpenStax userinfo body: %s", string(data)) + if err = common.MapClaims(data, customClaims); err != nil { + log.Error(err) + return err + } + oxUser := structs.OpenStaxUser{} + if err = json.Unmarshal(data, &oxUser); err != nil { + log.Error(err) + return err + } + + oxUser.PrepareUserData() + user.Email = oxUser.Email + user.Name = oxUser.Name + user.Username = oxUser.Username + user.ID = oxUser.ID + user.PrepareUserData() + return nil +} diff --git a/pkg/responses/responses.go b/pkg/responses/responses.go new file mode 100644 index 00000000..87013333 --- /dev/null +++ b/pkg/responses/responses.go @@ -0,0 +1,135 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package responses + +import ( + "html/template" + "net/http" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "go.uber.org/zap" + "golang.org/x/net/context" +) + +// Index variables passed to index.tmpl +type Index struct { + Msg string + TestURLs []string + Testing bool + DocumentRoot string +} + +var ( + indexTemplate *template.Template + log *zap.SugaredLogger + // fastlog *zap.Logger + + // errorTemplate *template.Template + // errNotAuthorized = errors.New("not authorized") +) + +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger + // fastlog = cfg.Logging.FastLogger + + log.Debugf("responses.Configure() attempting to parse embedded templates") + indexTemplate = template.Must(template.ParseFS(cfg.Templates, "templates/index.tmpl")) +} + +// RenderIndex render the response as an HTML page, mostly used in testing +func RenderIndex(w http.ResponseWriter, msg string) { + if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURLs: cfg.Cfg.TestURLs, Testing: cfg.Cfg.Testing, DocumentRoot: cfg.Cfg.DocumentRoot}); err != nil { + log.Error(err) + } +} + +// renderError html error page +// something terse for the end user +func renderError(w http.ResponseWriter, msg string, status int) { + log.Debugf("rendering error for user: %s", msg) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + if err := indexTemplate.Execute(w, &Index{Msg: msg, DocumentRoot: cfg.Cfg.DocumentRoot}); err != nil { + log.Error(err) + } +} + +// OK200 returns "200 OK" +func OK200(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("200 OK\n")) + if err != nil { + log.Error(err) + } +} + +// Redirect302 redirect to the specified rURL +func Redirect302(w http.ResponseWriter, r *http.Request, rURL string) { + if cfg.Cfg.Testing { + cfg.Cfg.TestURLs = append(cfg.Cfg.TestURLs, rURL) + RenderIndex(w, "302 redirect to: "+rURL) + return + } + http.Redirect(w, r, rURL, http.StatusFound) +} + +// Error400 Bad Request +func Error400(w http.ResponseWriter, r *http.Request, e error) { + cancelClearSetError(w, r, e) + renderError(w, "400 Bad Request", http.StatusBadRequest) +} + +// Error401 Unauthorized, the standard error returned when failing /validate +// this is captured by nginx, which converts the 401 into 302 to the login page +func Error401(w http.ResponseWriter, r *http.Request, e error) { + cancelClearSetError(w, r, e) + http.Error(w, e.Error(), http.StatusUnauthorized) + // renderError(w, "401 Unauthorized") +} + +// Error401HTTP +func Error401HTTP(w http.ResponseWriter, r *http.Request, e error) { + cancelClearSetError(w, r, e) + renderError(w, e.Error(), http.StatusUnauthorized) +} + +// Error403 Forbidden +// if there's an error during /auth or if they don't pass validation in /auth +func Error403(w http.ResponseWriter, r *http.Request, e error) { + cancelClearSetError(w, r, e) + renderError(w, "403 Forbidden", http.StatusForbidden) +} + +// Error500 Internal Error +// something is not right, hopefully this never happens +func Error500(w http.ResponseWriter, r *http.Request, e error) { + cancelClearSetError(w, r, e) + log.Infof("If this error persists it may be worthy of a bug report but please check your setup first. See the README at %s", cfg.Branding.URL) + renderError(w, "500 - Internal Server Error", http.StatusInternalServerError) +} + +// cancelClearSetError convenience method to keep it DRY +func cancelClearSetError(w http.ResponseWriter, r *http.Request, e error) { + log.Warn(e) + cookie.ClearCookie(w, r) + w.Header().Set(cfg.Cfg.Headers.Error, e.Error()) + addErrandCancelRequest(r) +} + +// cfg.ErrCtx is tested by `jwtmanager.JWTCacheHandler` +func addErrandCancelRequest(r *http.Request) { + ctx, cancel := context.WithCancel(r.Context()) + ctx = context.WithValue(ctx, cfg.ErrCtxKey, true) + *r = *r.Clone(ctx) + cancel() // we're done +} diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go index a11185d6..368fb0f8 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -1,16 +1,83 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package structs +import ( + "strconv" +) + +// CustomClaims Temporary struct storing custom claims until JWT creation. +type CustomClaims struct { + Claims map[string]interface{} +} + +// UserI each *User struct must prepare the data for being placed in the JWT +type UserI interface { + PrepareUserData() +} + // User is inherited. type User struct { - Name string `json:"name"` - Email string `json:"email"` + // TODO: set Provider here so that we can pass it to db + // populated by db (via mapstructure) or from provider (via json) + // Provider string `json:"provider",mapstructure:"provider"` + Username string `json:"username" mapstructure:"username"` + Name string `json:"name" mapstructure:"name"` + Email string `json:"email" mapstructure:"email"` CreatedOn int64 `json:"createdon"` LastUpdate int64 `json:"lastupdate"` - ID int `json:"id",mapstructure:"id"` + // don't populate ID from json https://github.com/vouch/vouch-proxy/issues/185 + ID int `json:"-" mapstructure:"id"` // jwt.StandardClaims + + TeamMemberships []string +} + +// PrepareUserData implement PersonalData interface +func (u *User) PrepareUserData() { + if u.Username == "" { + u.Username = u.Email + } +} + +// AzureUser is a retrieved and authenticated user from Azure AD +type AzureUser struct { + User + Sub string `json:"sub"` + UPN string `json:"upn"` + PreferredUsername string `json:"preferred_username"` +} + +// PrepareUserData implement PersonalData interface +func (u *AzureUser) PrepareUserData() { + // AzureAD uses the 'upn' (UserPrincipleName) field to store the email address of the user + // See https://docs.microsoft.com/en-us/azure/active-directory/hybrid/plan-connect-userprincipalname + + if u.Username == "" { + u.Username = u.UPN + } + + if u.Username == "" { + u.Username = u.PreferredUsername + } + + if u.Email == "" { + u.Email = u.UPN + } } // GoogleUser is a retrieved and authentiacted user from Google. +// unused! +// TODO: see if these should be pointers to the *User object as per +// https://golang.org/doc/effective_go.html#embedding type GoogleUser struct { User Sub string `json:"sub"` @@ -24,42 +91,142 @@ type GoogleUser struct { // jwt.StandardClaims } -// GithubUser is a retrieved and authentiacted user from Github. -type GithubUser struct { +// PrepareUserData implement PersonalData interface +func (u *GoogleUser) PrepareUserData() { + u.Username = u.Email +} + +// ADFSUser Active Directory user record +type ADFSUser struct { User + Sub string `json:"sub"` + UPN string `json:"upn"` + // UniqueName string `json:"unique_name"` + // PwdExp string `json:"pwd_exp"` + // SID string `json:"sid"` + // Groups string `json:"groups"` + // jwt.StandardClaims +} + +// PrepareUserData implement PersonalData interface +func (u *ADFSUser) PrepareUserData() { + u.Username = u.UPN +} + +// GitHubUser is a retrieved and authentiacted user from GitHub. +type GitHubUser struct { + User + Login string `json:"login"` Picture string `json:"avatar_url"` // jwt.StandardClaims } -// GCredentials google credentials -// loaded from yaml config -type GCredentials struct { - ClientID string `mapstructure:"client_id"` - ClientSecret string `mapstructure:"client_secret"` - RedirectURLs []string `mapstructure:"callback_urls"` - PreferredDomain string `mapstructre:"preferredDomain"` +// GitHubTeamMembershipState for GitHub team api call +type GitHubTeamMembershipState struct { + State string `json:"state"` } -// GenericOauth provides endoint for access -type GenericOauth struct { - ClientID string `mapstructure:"client_id"` - ClientSecret string `mapstructure:"client_secret"` - AuthURL string `mapstructure:"auth_url"` - TokenURL string `mapstructure:"token_url"` - RedirectURL string `mapstructure:"callback_url "` - Scopes []string `mapstructure:"scopes"` - UserInfoURL string `mapstructure:"user_info_url"` - Provider string `mapstructure:"provider"` +// PrepareUserData implement PersonalData interface +func (u *GitHubUser) PrepareUserData() { + // always use the u.Login as the u.Username + u.Username = u.Login +} + +// IndieAuthUser see indieauth.net +type IndieAuthUser struct { + User + URL string `json:"me"` +} + +// PrepareUserData implement PersonalData interface +func (u *IndieAuthUser) PrepareUserData() { + u.Username = u.URL +} + +// Contact used for OpenStaxUser +type Contact struct { + Type string `json:"type"` + Value string `json:"value"` + Verified bool `json:"is_verified"` +} + +// OpenStaxUser is a retrieved and authenticated user from OpenStax Accounts +type OpenStaxUser struct { + User + Contacts []Contact `json:"contact_infos"` +} + +// PrepareUserData implement PersonalData interface +func (u *OpenStaxUser) PrepareUserData() { + if u.Email == "" { + // assuming first contact of type "EmailAddress" + for _, c := range u.Contacts { + if c.Type == "EmailAddress" && c.Verified { + u.Email = c.Value + break + } + } + } +} + +// Ocs used for NextcloudUser +type Ocs struct { + Data struct { + UserID string `json:"id"` + Email string `json:"email"` + } `json:"data"` +} + +// NextcloudUser User of Nextcloud retreived from ocs endpoint +type NextcloudUser struct { + User + Ocs Ocs `json:"ocs"` +} + +// PrepareUserData NextcloudUser +func (u *NextcloudUser) PrepareUserData() { + if u.Username == "" { + u.Username = u.Ocs.Data.UserID + u.Email = u.Ocs.Data.Email + } +} + +// AlibabaUser Aliyun +type AlibabaUser struct { + User + Data AliData `json:"data"` + // jwt.StandardClaims +} + +// PrepareUserData implement PersonalData interface +func (u *AlibabaUser) PrepareUserData() { + u.Username = u.Data.Username + u.Name = u.Data.Nickname + u.Email = u.Data.Email + id, _ := strconv.Atoi(u.Data.ID) + u.ID = id +} + +// AliData `data` subobject of Alibaba User response +// https://github.com/vouch/vouch-proxy/issues/344 +type AliData struct { + Sub string `json:"sub"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + ID string `json:"ou_id"` + Phone string `json:"phone_number"` + OuName string `json:"ou_name"` } // Team has members and provides acess to sites type Team struct { - Name string `json:"name",mapstructure:"name"` - Members []string `json:"members",mapstructure:"members"` // just the emails - Sites []string `json:"sites",mapstructure:"sites"` // just the domains - CreatedOn int64 `json:"createdon",mapstructure:"createdon"` - LastUpdate int64 `json:"lastupdate",mapstructure:"lastupdate"` - ID int `json:"id",mapstructure:"id"` + Name string `json:"name" mapstructure:"name"` + Members []string `json:"members" mapstructure:"members"` // just the emails + Sites []string `json:"sites" mapstructure:"sites"` // just the domains + CreatedOn int64 `json:"createdon" mapstructure:"createdon"` + LastUpdate int64 `json:"lastupdate" mapstructure:"lastupdate"` + ID int `json:"id" mapstructure:"id"` } // Site is the basic unit of auth @@ -67,5 +234,23 @@ type Site struct { Domain string `json:"domain"` CreatedOn int64 `json:"createdon"` LastUpdate int64 `json:"lastupdate"` - ID int `json:"id",mapstructure:"id"` + ID int `json:"id" mapstructure:"id"` +} + +// PTokens provider tokens (from the IdP) +type PTokens struct { + PAccessToken string + PIdToken string +} + +// DiscordUser deserializes values from the Discord User Object: https://discord.com/developers/docs/resources/user#user-object-user-structure +type DiscordUser struct { + Id string `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator"` + GlobalName string `json:"global_name"` + Email string `json:"email"` + Verified bool `json:"verified"` + + PreparedUsername string } diff --git a/pkg/timelog/timelog.go b/pkg/timelog/timelog.go index 65ce51dd..9d69679c 100644 --- a/pkg/timelog/timelog.go +++ b/pkg/timelog/timelog.go @@ -1,76 +1,79 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + package timelog import ( "context" + "fmt" "net/http" "time" - lctx "github.com/bnfinet/lasso/pkg/context" - - log "github.com/Sirupsen/logrus" - // "github.com/mattn/go-isatty" + "github.com/vouch/vouch-proxy/pkg/capturewriter" + "github.com/vouch/vouch-proxy/pkg/cfg" + "go.uber.org/zap" ) var ( - green = string([]byte{27, 91, 57, 55, 59, 52, 50, 109}) - white = string([]byte{27, 91, 57, 48, 59, 52, 55, 109}) - yellow = string([]byte{27, 91, 57, 55, 59, 52, 51, 109}) - red = string([]byte{27, 91, 57, 55, 59, 52, 49, 109}) - blue = string([]byte{27, 91, 57, 55, 59, 52, 52, 109}) - magenta = string([]byte{27, 91, 57, 55, 59, 52, 53, 109}) - cyan = string([]byte{27, 91, 57, 55, 59, 52, 54, 109}) - reset = string([]byte{27, 91, 48, 109}) + req = int64(0) + avgLatency = int64(0) + log *zap.SugaredLogger ) -// HERE you left off trying to figure out how to implement middleware in gorilla mux -func TimeLog(nextHandler http.Handler) http.HandlerFunc { +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger + + capturewriter.Configure() + +} + +// TimeLog records how long it takes to process the http request and produce the response (latency) +func TimeLog(nextHandler http.Handler) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - log.Debugf("Request received : %v\n", r) + // log.Debugf("Request received : %v", r) start := time.Now() // make the call + v := capturewriter.CaptureWriter{ResponseWriter: w, StatusCode: 0} ctx := context.Background() - nextHandler.ServeHTTP(w, r.WithContext(ctx)) + nextHandler.ServeHTTP(&v, r.WithContext(ctx)) // Stop timer end := time.Now() - log.Debug("Request handled successfully") - - latency := end.Sub(start) - clientIP := r.RemoteAddr - method := r.Method - - // var statusCode int - // var statusColor string - statusCode := ctx.Value(lctx.StatusCode) - log.Debugf("statuscode: %v", statusCode) - if statusCode == nil { - statusCode = 200 - } - statusColor := colorForStatus(statusCode.(int)) - - path := r.URL.Path - host := r.Host - referer := r.Header.Get("Referer") - - log.Infof("|%s %3d %s| %13v | %s | %s %s %s | %s", - statusColor, statusCode, reset, - latency, - clientIP, - method, host, path, - referer) - } -} -func colorForStatus(code int) string { - switch { - case code >= 200 && code < 300: - return green - case code >= 300 && code < 400: - return white - case code >= 400 && code < 500: - return yellow - default: - return red + go func() { + latency := end.Sub(start) + req++ + avgLatency = avgLatency + ((int64(latency) - avgLatency) / req) + // log.Debugf("Request handled successfully: %v", v.GetStatusCode()) + var statusCode = v.GetStatusCode() + + path := r.URL.Path + host := r.Host + referer := r.Header.Get("Referer") + clientIP := r.RemoteAddr + method := r.Method + + log.Infow(fmt.Sprintf("|%d| %10v %s", statusCode, time.Duration(latency), path), + "statusCode", statusCode, + "request", req, + "latency", time.Duration(latency), + "avgLatency", time.Duration(avgLatency), + "ipPort", clientIP, + "method", method, + "host", host, + "path", path, + "referer", referer, + ) + }() + } } diff --git a/pkg/transciever/client.go b/pkg/transciever/client.go deleted file mode 100644 index 944ed28b..00000000 --- a/pkg/transciever/client.go +++ /dev/null @@ -1,236 +0,0 @@ -package transciever - -import ( - "encoding/json" - "io" - "net/http" - "time" - - "github.com/bnfinet/lasso/pkg/model" - "github.com/bnfinet/lasso/pkg/structs" - - log "github.com/Sirupsen/logrus" - "github.com/mitchellh/mapstructure" - - "github.com/gorilla/websocket" -) - -// based on -// https://github.com/gorilla/websocket/blob/master/examples/chat/client.go - -var allConns map[*websocket.Conn]bool - -const ( - // Time allowed to write a message to the peer. - writeWait = 10 * time.Second - // Time allowed to read the next pong message from the peer. - pongWait = 60 * time.Second - // Send pings to peer with this period. Must be less than pongWait. - pingPeriod = (pongWait * 9) / 10 - // Maximum message size allowed from peer. - maxMessageSize = 512 -) - -var ( - newline = []byte{'\n'} - space = []byte{' '} -) - -var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { return true }, -} - -// Client is a middleman between the websocket connection and the hub. -type Client struct { - hub *Hub - - // The websocket connection. - conn *websocket.Conn - - // Buffered channel of outbound messages. - send chan []byte -} - -type pkg struct { - T string `json:"type"` - D interface{} `json:"data"` -} - -// readPump pumps messages from the websocket connection to the hub. -// -// The application runs readPump in a per-connection goroutine. The application -// ensures that there is at most one reader on a connection by executing all -// reads from this goroutine. -func (c *Client) readPump() { - defer func() { - c.hub.unregister <- c - c.conn.Close() - }() - c.conn.SetReadLimit(maxMessageSize) - c.conn.SetReadDeadline(time.Now().Add(pongWait)) - c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) - for { - var p pkg - err := c.conn.ReadJSON(&p) - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { - log.Errorf("error: %v", err) - } - break - } - log.Infof("ws message: %v", p) - - // _, message, err := c.conn.ReadMessage() - // if err != nil { - // if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { - // log.Errorf("error: %v", err) - // } - // break - // } - // message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) - // json.Unmarshal(message, &p) - // log.Infof("ws message: %s, %v", message, p) - if p.T == "getusers" { - c.shipUsers() - } else if p.T == "getsites" { - c.shipSites() - } else if p.T == "getteams" { - c.shipTeams() - } else if p.T == "updateteam" { - c.updateTeam(p.D) - } - // c.hub.broadcast <- []byte(p) - } -} - -func (c *Client) updateTeam(data interface{}) { - log.Debugf("creating team from %v", data) - - t := structs.Team{} - mapstructure.Decode(data, &t) - // if err := json.Unmarshal(data, &t); err != nil { - // log.Error(err) - // return - // } - model.PutTeam(t) - c.shipTeams() -} - -// writePump pumps messages from the hub to the websocket connection. -// -// A goroutine running writePump is started for each connection. The -// application ensures that there is at most one writer to a connection by -// executing all writes from this goroutine. -func (c *Client) writePump() { - ticker := time.NewTicker(pingPeriod) - defer func() { - ticker.Stop() - c.conn.Close() - }() - for { - select { - case message, ok := <-c.send: - c.conn.SetWriteDeadline(time.Now().Add(writeWait)) - if !ok { - // The hub closed the channel. - c.conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - - w, err := c.conn.NextWriter(websocket.TextMessage) - if err != nil { - return - } - w.Write(message) - - // Add queued chat messages to the current websocket message. - n := len(c.send) - for i := 0; i < n; i++ { - w.Write(newline) - w.Write(<-c.send) - } - - if err := w.Close(); err != nil { - return - } - case <-ticker.C: - c.conn.SetWriteDeadline(time.Now().Add(writeWait)) - if err := c.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { - return - } - } - } -} - -func (c *Client) shipUsers() { - var users []structs.User - model.AllUsers(&users) - log.Debugf("shipping users %v", users) - c.shipping("users", users) -} - -func (c *Client) shipSites() { - var sites []structs.Site - model.AllSites(&sites) - log.Debugf("shipping sites %v", sites) - c.shipping("sites", sites) -} - -func (c *Client) shipTeams() { - var teams []structs.Team - model.AllTeams(&teams) - log.Debugf("shipping teams %v", teams) - c.shipping("teams", teams) -} - -func (c *Client) shipping(t string, v interface{}) { - // d, _ := json.Marshal(v) - p := &pkg{t, v} - j, err := json.Marshal(p) - if err != nil { - log.Error(err) - } - c.send <- j -} - -// serveWs handles websocket requests from the peer. -func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Println(err) - return - } - client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} - client.hub.register <- client - go client.writePump() - client.readPump() -} - -func Echo(conn *websocket.Conn) error { - messageType, r, err := conn.NextReader() - if err != nil { - return err - } - w, err := conn.NextWriter(messageType) - if err != nil { - return err - } - if _, err := io.Copy(w, r); err != nil { - return err - } - if err := w.Close(); err != nil { - return err - } - return nil -} - -func readLoop(conn *websocket.Conn) { - for { - if _, _, err := conn.NextReader(); err != nil { - conn.Close() - break - } - } -} diff --git a/pkg/transciever/hub.go b/pkg/transciever/hub.go deleted file mode 100644 index c79fd347..00000000 --- a/pkg/transciever/hub.go +++ /dev/null @@ -1,49 +0,0 @@ -package transciever - -// Hub hub maintains the set of active clients and broadcasts messages to the -// clients. -type Hub struct { - // Registered clients. - clients map[*Client]bool - - // Inbound messages from the clients. - broadcast chan []byte - - // Register requests from the clients. - register chan *Client - - // Unregister requests from clients. - unregister chan *Client -} - -func newHub() *Hub { - return &Hub{ - broadcast: make(chan []byte), - register: make(chan *Client), - unregister: make(chan *Client), - clients: make(map[*Client]bool), - } -} - -func (h *Hub) run() { - for { - select { - case client := <-h.register: - h.clients[client] = true - case client := <-h.unregister: - if _, ok := h.clients[client]; ok { - delete(h.clients, client) - close(client.send) - } - case message := <-h.broadcast: - for client := range h.clients { - select { - case client.send <- message: - default: - close(client.send) - delete(h.clients, client) - } - } - } - } -} diff --git a/pkg/transciever/transciever.go b/pkg/transciever/transciever.go deleted file mode 100644 index 36537db9..00000000 --- a/pkg/transciever/transciever.go +++ /dev/null @@ -1,47 +0,0 @@ -package transciever - -import ( - "net/http" - - log "github.com/Sirupsen/logrus" -) - -// WSHandler implements the Handler Interface -type WSHandler struct{} - -// WS to handle -var WS = &WSHandler{} - -type HubHolder struct { - Hub *Hub -} - -var hh = &HubHolder{ - Hub: newHub(), -} - -// NewHub -func init() { - log.Info("hub %v", hh.Hub) - go hh.Hub.run() -} - -func (WS WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - log.Infof("ws endpoint") - // jwt := handlers.FindJWT(r) - // if jwt == "" { - // http.Error(w, "your mother", http.StatusUnauthorized) - // return - // } - // claims, err := handlers.ClaimsFromJWT(jwt) - // // lookup the User - // user := structs.User{} - // err = model.User([]byte(claims.Email), &user) - // if err != nil { - // // no email in jwt - // http.Error(w, "your mother", http.StatusUnauthorized) - // return - // } - log.Info("hub %v", hh.Hub) - serveWs(hh.Hub, w, r) -} diff --git a/static/css/main.css b/static/css/main.css index e094e29a..6ae12884 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,26 +1,52 @@ +@charset "UTF-8"; + +body, +button, +input, +select, +textarea { + font-family: BlinkMacSystemFont, -apple-system, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + 'Helvetica', 'Arial', sans-serif; +} + +code, +pre { + -moz-osx-font-smoothing: auto; + -webkit-font-smoothing: auto; + font-family: monospace; +} + body { - background-image: url("/img/background.png"); - background-color: #cccccc; -} - -h1 { - text-align: center; - /*color: green;*/ - /*text-decoration: overline;*/ - text-shadow: 2px 2px #ff0000; -} - -button { - background-color: #4CAF50; /* Green */ - border: none; - color: white; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - border-radius: 12px; - position: absolute; - top: 45%; - left: 42%; + background-color: #ffffff; + margin: 5px 10px; +} + +.top img { + height: 50px; + width: 50px; + margin: 10px; + vertical-align: middle; +} + +.top span { + margin: 5px; + font-size: 1.5em; +} + +.top a { + text-decoration: none; +} + +.top a, +.bottom a { + color: #000000; +} + +.content { + margin: 5px 10px; +} + +.test { + clear: both; } diff --git a/static/img/background.png b/static/img/background.png deleted file mode 100644 index 73864ecf..00000000 Binary files a/static/img/background.png and /dev/null differ diff --git a/static/img/favicon.ico b/static/img/favicon.ico index 20c48827..bb2b07cc 100644 Binary files a/static/img/favicon.ico and b/static/img/favicon.ico differ diff --git a/static/img/multicolor_V_500x500.png b/static/img/multicolor_V_500x500.png new file mode 100644 index 00000000..0ddbb8bd Binary files /dev/null and b/static/img/multicolor_V_500x500.png differ diff --git a/templates/index.tmpl b/templates/index.tmpl index 12502c6d..a8571b22 100644 --- a/templates/index.tmpl +++ b/templates/index.tmpl @@ -1,19 +1,51 @@ - - + + + + + + + Vouch Proxy - {{ .Msg }} + + +
+

{{ .Msg }}

+ +{{ if .Testing }} +

+

-- test mode --

+The config file includes testing: true +

+All 302 redirects will be captured and presented as links here -

{{ .Msg }}.

- +{{ end }} +
+For support, please contact your network administrator or whomever configured Nginx to use Vouch Proxy. +

+For help with Vouch Proxy or to file a bug report, please visit https://github.com/vouch/vouch-proxy +

+

+