diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..10309e91 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +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 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..2459d947 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/open-a-github-issue-to-receive-support.md @@ -0,0 +1,38 @@ +--- +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)***. + +And turn on `vouch.testing` before you ask for support. + +**Use a Paste Service** +We like [hasteb.in](https://hasteb.in/), but a [gist](https://gist.github.com/) is also acceptable +Do not post logs and configs to this issue + +**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/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 00000000..ca6d44f3 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,43 @@ +name: Docker build and push voucher/vouch-proxy:latest-arm + +on: + push: + branches: + - master + +jobs: + Publish-to-docker: + 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/.gitignore b/.gitignore index 5941f189..48af7045 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ -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/google_config.json -.vscode/* \ No newline at end of file +config/secret +!config/testing/* +pkg/model/storage-test.db +.vscode/* +coverage.out +coverage.html \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..7c7fe600 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +language: go +go_import_path: github.com/vouch/vouch-proxy + +sudo: false + +services: + - docker + +go: + - "1.13" + +before_install: + - ./do.sh goget + +script: + - ./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: "chat.freenode.net#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/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/Dockerfile b/Dockerfile index e2f797e9..3ad05ac5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,29 @@ -# bfoote/lasso -# https://github.com/bnfinet/lasso -FROM golang:1.8 +# voucher/vouch-proxy +# https://github.com/vouch/vouch-proxy +FROM golang:1.13 AS builder + +LABEL maintainer="vouch@bnf.net" + +RUN mkdir -p ${GOPATH}/src/github.com/vouch/vouch-proxy +WORKDIR ${GOPATH}/src/github.com/vouch/vouch-proxy -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 go-wrapper download # "go get -d -v ./..." +# RUN ./do.sh build # see `do.sh` for vouch build details +# 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 templates/ templates/ +# see note for /static in main.go +COPY static /static +COPY --from=builder /go/bin/vouch-proxy /vouch-proxy 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..1d120cd6 --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,30 @@ +# voucher/vouch-proxy +# https://github.com/vouch/vouch-proxy +FROM golang:1.13 AS builder + +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 go-wrapper download # "go get -d -v ./..." +# RUN ./do.sh build # see `do.sh` for vouch build details +# RUN go-wrapper install # "go install -v ./..." + +RUN ./do.sh goget +RUN ./do.sh gobuildstatic # see `do.sh` for vouch-proxy build details +RUN ./do.sh install + +FROM alpine:latest +LABEL maintainer="vouch@bnf.net" +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY templates/ templates/ +# see note for /static in main.go +COPY static /static +COPY do.sh /do.sh +COPY --from=builder /go/bin/vouch-proxy /vouch-proxy +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..0483beef 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,339 @@ -# 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) +[![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) +[![Docker pulls](https://img.shields.io/docker/pulls/voucher/vouch-proxy.svg)](https://hub.docker.com/r/voucher/vouch-proxy/) +[![GitHub version](https://badge.fury.io/gh/vouch%2Fvouch-proxy.svg)](https://badge.fury.io/gh/vouch%2Fvouch-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. -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 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 +- [GitHub](https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/) +- GitHub Enterprise +- [IndieAuth](https://indieauth.spec.indieweb.org/) +- [Okta](https://developer.okta.com/blog/2018/08/28/nginx-auth-request) +- [ADFS](https://github.com/vouch/vouch-proxy/pull/68) +- [AWS Cognito](https://github.com/vouch/vouch-proxy/issues/105) +- [Gitea](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_gitea) +- 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) +- [Nextcloud](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/oauth2.html) +- most other OpenID Connect (OIDC) providers + +Please do let us know when you have deployed Vouch Proxy with your preffered IdP or library so we can update the list. + +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 ## Installation -* `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... +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` where your apps are running on `app1.yourdomain.com` and `app2.yourdomain.com`. + +- `cp ./config/config.yml_example ./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/) + - be sure to direct the callback URL to the `/auth` endpoint +- configure Nginx... + +The following Nginx config assumes.. + +- Nginx, `vouch.yourdomain.com` and `dev.yourdomain.com` are running on the same server +- both domains are served as `https` and have valid certs (if not, change to `listen 80`) ```{.nginxconf} 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/dev.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dev.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 ```{.nginxconf} server { - listen 80 default_server; - server_name lasso.yourdomain.com; + 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_set_header Host lasso.yourdomain.com; - proxy_pass http://127.0.0.1:9090; + proxy_pass http://127.0.0.1:9090; + # be sure to pass the original host header + proxy_set_header Host $http_host; } } - ``` +An example of using Vouch Proxy with Nginx cacheing of the proxied validation request is available in [issue #76](https://github.com/vouch/vouch-proxy/issues/76#issuecomment-464028743). + +If you're protecting an API with Vouch Proxy you may need to configure Nginx to handle `OPTIONS` requests in the `/validate` block [issue #216](https://github.com/vouch/vouch-proxy/issues/216). + +Additional Nginx configurations can be found in the [examples](https://github.com/vouch/vouch-proxy/tree/master/examples) directory. + ## Running from Docker -* `./do.sh drun` +```bash +docker run -d \ + -p 9090:9090 \ + --name vouch-proxy \ + -v ${PWD}/config:/config \ + voucher/vouch-proxy +``` + +The [voucher/vouch-proxy](https://hub.docker.com/r/voucher/vouch-proxy/) Docker image is an automated build on Docker Hub. In addition to `voucher/vouch-proxy:latest` (based on [scratch](https://docs.docker.com/samples/library/scratch/)) there are versioned images as `voucher/vouch-proxy:x.y.z` and an [alpine](https://docs.docker.com/samples/library/alpine/) based `voucher/vouch-proxy:alpine` for the current version. + +[https://hub.docker.com/r/voucher/vouch-proxy/builds/](https://hub.docker.com/r/voucher/vouch-proxy/builds/) + +## 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; +``` + +Helm Charts are maintained by [halkeye](https://github.com/halkeye) and are available at [https://github.com/halkeye-helm-charts/vouch](https://github.com/halkeye-helm-charts/vouch) / [https://halkeye.github.io/helm-charts/](https://halkeye.github.io/helm-charts/) + +## Compiling from source and running the binary -And that's it! Or if you can examine the docker command in `do.sh` +```bash + ./do.sh goget + ./do.sh build + ./vouch-proxy +``` + +## /login and /logout endpoint redirection + +As of `v0.11.0` we have put additional checks 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... -The [bfoote/lasso](https://hub.docker.com/r/bfoote/lasso/) Docker image is an automated build on Docker Hub +- 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) -## Running from source +### /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 ``` - go get ./... - go build - ./lasso + +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 + - http://.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 ``` -## 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. +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.. + +- **turn on `vouch.testing: true`** and set `vouch.logLevel: debug`. +- use [hasteb.in](https://hasteb.in/), or another **paste service** or a [gist](https://gist.github.com/) to provide your logs and config. **_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 yourdomain.com [yourotherdomain.com]` which will create a redacted version of your config and logs + - and follow the instructions at the end to redact your Nginx config +- all of those go into [hasteb.in](https://hasteb.in/) or a [gist](https://gist.github.com/) +- then [open a new issue](https://github.com/vouch/vouch-proxy/issues/new) in this repository +- or visit our IRC channel [#vouch](irc://freenode.net/#vouch) on freenode + +### submitting a Pull Request for a new feature + +I really love Vouch Proxy! I wish it did XXXX... + +Please make a proposal before you spend your time and our time integrating a new feature. + +Code contributions should.. + +- include unit tests and in some cases end-to-end tests +- be formatted with `go fmt` +- not break existing setups without a clear reason (usually security related) +- and generally be discussed beforehand in a GitHub issue + +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. + +## 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 `https://vouch.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://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 $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 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 OAuth app, Google 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 Vouch. 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..c90e052a 100644 --- a/config/config.yml_example +++ b/config/config.yml_example @@ -1,78 +1,203 @@ +# vouch config -# lasso config +# you should probably start with one of the other configs in the example directory +# vouch proxy does a fairly good job of setting its config to sane defaults +# be aware of your indentation, the only top level elements are `vouch` and `oauth`. -lasso: +vouch: # logLevel: debug logLevel: info + + # 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 port: 9090 - # each of these domains must serve the url https://lasso.$domains[0] https://lasso.$domains[1] ... - # usually you'll just have one + + # 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 + # allowAllUsers: false + + # Setting publicAccess: true will accept all requests, even without a cookie. + # 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 + # 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 + + # regexWhiteList - (optional) allows only the listed usernames + # Note: whiteList always takes precidence and disables regexWhiteList + # + # Single-quotes(') are to prevent the yaml parser from misinterpreting the line) + # usernames are usually email addresses (google, most oidc providers) or login/username for github and github enterprise + # + # regexWhiteList: + # - '^bob[4-9]?@yourdomain.com$' + # - '^alice@.+\.com$' + # - '^alice@your(other)?domain\.com$' + # - '^joe@yourdomain\.com$' + # - '^j[aneo]{1,3}@yourdomain\.(org|net|com)$' + jwt: - issuer: Lasso - maxAge: 240 + # secret - a random string used to cryptographically sign the jwt + # 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.. + # 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 + issuer: Vouch + # number of minutes until jwt expires + maxAge: 240 + # compress the jwt compress: true + cookie: # name of cookie to store the jwt - name: Lasso - secure: false + name: VouchCookie + # optionally force the domain of the cookie to set + # domain: yourdomain.com + secure: true httpOnly: true - headers: - sso: X-Lasso-Token - redirect: X-Lasso-Requested-URI - db: - file: data/lasso_bolt.db + # Set cookie maxAge to 0 to delete the cookie every time the browser is closed. + maxAge: 14400 + # Set SameSite attribute to restrict browser behaviour browser to send this cookie along with cross-site requests. + # 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 + name: VouchSession + # key - a cryptographic string used to store the session variable + # 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 + key: you_random_key + + + headers: + jwt: X-Vouch-Token + querystring: access_token + redirect: X-Vouch-Requested-URI + + # 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. Every request will process the cookies in order to extract and create the + # additional headers which get returned. 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 + # 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 + # X-Vouch-IdP-Claims-Groups + # 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-`) + # 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 + # 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 + # 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) + test_url: http://yourdomain.com + + # 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 Config +# OAuth Provider +# 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 + scopes: + - openid + - email + - profile + callback_url: http://vouch.yourdomain.com:9090/auth + + # 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_adfs b/config/config.yml_example_adfs new file mode 100644 index 00000000..7189ce67 --- /dev/null +++ b/config/config.yml_example_adfs @@ -0,0 +1,18 @@ +# vouch config +# bare minimum to get vouch running with adfs + +vouch: + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate to ADFS + allowAllUsers: true + +oauth: + provider: adfs + client_id: k8s + client_secret: sauceSecret + auth_url: https://adfs.example.com/adfs/oauth2/authorize/ + token_url: https://adfs.example.com/adfs/oauth2/token/ + scopes: + - openid + - email + - profile + callback_url: https://vouch.example.com/auth \ No newline at end of file diff --git a/config/config.yml_example_gitea b/config/config.yml_example_gitea new file mode 100644 index 00000000..dbcae321 --- /dev/null +++ b/config/config.yml_example_gitea @@ -0,0 +1,22 @@ + +# vouch config +# bare minimum to get vouch running with Gitea + +vouch: + domains: + - vouch.example + + # set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at Gitea + # allowAllUsers: true + +oauth: + # replace "gitea.example" with the domain your Gitea instance runs on + # create a new OAuth application at: + # https://gitea.example/user/settings/applications + provider: github + client_id: xxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + callback_url: https://vouch.example/auth + auth_url: https://gitea.example/login/oauth/authorize + token_url: https://gitea.example/login/oauth/access_token + user_info_url: https://gitea.example/api/v1/user?token= diff --git a/config/config.yml_example_github b/config/config.yml_example_github new file mode 100644 index 00000000..0e7c051e --- /dev/null +++ b/config/config.yml_example_github @@ -0,0 +1,35 @@ + +# vouch config +# bare minimum to get vouch 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 + + # 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 + 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..384e721b --- /dev/null +++ b/config/config.yml_example_github_enterprise @@ -0,0 +1,45 @@ +# vouch config +# bare minimum to get vouch 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: + - yoursite.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 + # 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.yoursite.com/settings/applications/new + provider: github + client_id: xxxxxxxxxxxxxxxxxxxx + client_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + auth_url: https://githubenterprise.yoursite.com/login/oauth/authorize + token_url: https://githubenterprise.yoursite.com/login/oauth/access_token + user_info_url: https://githubenterprise.yoursite.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.yoursite.com/api/v3/orgs/:org_id/teams/:team_slug/memberships/:username?access_token= + user_org_url: https://githubenterprise.yoursite.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_google b/config/config.yml_example_google new file mode 100644 index 00000000..9a93abfd --- /dev/null +++ b/config/config.yml_example_google @@ -0,0 +1,20 @@ + +# vouch config +# bare minimum to get vouch running with google + +vouch: + domains: + - yourdomain.com + - yourotherdomain.com + +oauth: + provider: google + # get credentials from... + # https://console.developers.google.com/apis/credentials + client_id: xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com + client_secret: xxxxxxxxxxxxxxxxxxxxxxxx + callback_urls: + - http://yourdomain.com:9090/auth + - http://yourotherdomain.com:9090/auth + preferredDomain: yourdomain.com + # endpoints 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..d3ed8b70 --- /dev/null +++ b/config/config.yml_example_homeassistant @@ -0,0 +1,35 @@ +# vouch config +# bare minimum to get vouch 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 the configured provider + allowAllUsers: false + + # 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 + + # Setting publicAccess: true will accept all requests, even without a cookie. + publicAccess: false + +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..52727bfc --- /dev/null +++ b/config/config.yml_example_indieauth @@ -0,0 +1,24 @@ + +# vouch config +# bare minimum to get vouch 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 + + # 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_nextcloud b/config/config.yml_example_nextcloud new file mode 100644 index 00000000..5c88f33c --- /dev/null +++ b/config/config.yml_example_nextcloud @@ -0,0 +1,31 @@ + +# vouch config +# bare minimum to get vouch 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 + +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..16b231ad --- /dev/null +++ b/config/config.yml_example_oidc @@ -0,0 +1,31 @@ + +# vouch config +# bare minimum to get vouch 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 + # allowAllUsers: true + +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/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..ab7d6b2a --- /dev/null +++ b/config/testing/handler_email.yml @@ -0,0 +1,13 @@ +vouch: + logLevel: debug + 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_url.yml b/config/testing/handler_login_url.yml new file mode 100644 index 00000000..ff7fdb9a --- /dev/null +++ b/config/testing/handler_login_url.yml @@ -0,0 +1,15 @@ +vouch: + domains: + - example.com + + cookie: + secure: false + + 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_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_regexwhitelist.yml b/config/testing/handler_regexwhitelist.yml new file mode 100644 index 00000000..f2934c72 --- /dev/null +++ b/config/testing/handler_regexwhitelist.yml @@ -0,0 +1,17 @@ +vouch: + logLevel: debug + domains: + - example.com + + regexWhiteList: + - 'test1?\@(domain|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_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/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/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/coverage_report.sh b/coverage_report.sh new file mode 100755 index 00000000..1d64f1fe --- /dev/null +++ b/coverage_report.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Generate test coverage statistics for Go packages. +# +# Works around the fact that `go test -coverprofile` currently does not work +# with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 +# +# Usage: script/coverage [--html|--coveralls] +# +# --html Additionally create HTML report and open it in browser +# --coveralls Push coverage statistics to coveralls.io +# + +set -e + +workdir=.cover +profile="$workdir/cover.out" +mode=count + +generate_cover_data() { + rm -rf "$workdir" + mkdir "$workdir" + + for pkg in "$@"; do + f="$workdir/$(echo $pkg | tr / -).cover" + go test -covermode="$mode" -coverprofile="$f" "$pkg" + done + + echo "mode: $mode" >"$profile" + grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" +} + +show_cover_report() { + go tool cover -${1}="$profile" +} + +push_to_coveralls() { + echo "Pushing coverage statistics to coveralls.io" + goveralls -coverprofile="$profile" +} + +generate_cover_data $(go list ./...) +show_cover_report func +case "$1" in +"") + ;; +--html) + show_cover_report html ;; +--coveralls) + push_to_coveralls ;; +*) + echo >&2 "error: invalid option: $1"; exit 1 ;; +esac diff --git a/do.sh b/do.sh index 779d51cf..3aef3c0b 100755 --- a/do.sh +++ b/do.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e # change dir to where this script is running CURDIR=${PWD} @@ -6,20 +7,33 @@ SCRIPT=$(readlink -f "$0") SDIR=$(dirname "$SCRIPT") cd $SDIR -export LASSO_ROOT=${GOPATH}/src/github.com/bnfinet/lasso/ +export VOUCH_ROOT=${GOPATH}/src/github.com/vouch/vouch-proxy/ -IMAGE=bfoote/lasso -GOIMAGE=golang:1.8 -NAME=lasso +IMAGE=voucher/vouch-proxy +GOIMAGE=golang:1.14 +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 FQDN=$(hostname --fqdn) + local SEMVER=$(git tag --list --sort="v:refname" | tail -n -1) + local BRANCH=$(git rev-parse --abbrev-ref HEAD) + go build -i -v -ldflags=" -X main.version=${VERSION} -X main.builddt=${DT} -X main.host=${FQDN} -X main.semver=${SEMVER} -X main.branch=${BRANCH}" . +} + +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 () { @@ -27,7 +41,9 @@ dbuild () { } gobuildstatic () { - CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + export CGO_ENABLED=0 + export GOOS=linux + build } drun () { @@ -40,7 +56,6 @@ drun () { -p ${HTTPPORT}:${HTTPPORT} --name $NAME -v ${SDIR}/config:/config - -v ${SDIR}/data:/data $IMAGE $* " echo $CMD @@ -49,123 +64,267 @@ 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 -t -v ./... } -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 both configs and some logs to https://hastebin.com/ and open an issue on GitHub at https://github.com/vouch/vouch-proxy/issues\n" +} + +_redact() { + SECRET_FIELDS=("client_id client_secret secret") + 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() { + export EXTRA_TEST_ARGS='-cover' + test + go tool cover -html=coverage.out -o coverage.html +} + +test() { + if [ -z "$VOUCH_CONFIG" ]; then + export VOUCH_CONFIG="$SDIR/config/testing/test_config.yml" + fi # 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; +} + +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 +} + +_print_license() { + local YEAR=$1; + if [ -z "$YEAR" ]; then + YEAR=$(date +%Y) + fi + cat < lasso_flow.jpg + $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] + $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'|'install'|'test'|'goget'|'gogo'|'watch'|'gobuildstatic'|'coverage'|'stats'|'usage'|'bug_report'|'test_logging'|'license') + 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..80c886bf --- /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..00be6e8b --- /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..83e7c619 --- /dev/null +++ b/examples/OpenResty/lua/group_auth.lua @@ -0,0 +1,32 @@ +-- ============================== +-- Group Authentication +-- via X-Vouch-IdP-Groups +-- ============================== +-- 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 \ No newline at end of file diff --git a/examples/OpenResty/lua/user_auth.lua b/examples/OpenResty/lua/user_auth.lua new file mode 100644 index 00000000..91f6c62a --- /dev/null +++ b/examples/OpenResty/lua/user_auth.lua @@ -0,0 +1,20 @@ +-- ============================== +-- User Authentication +-- via X-Vouch-User +-- ============================== +-- 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 \ No newline at end of file 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..584ef8fd --- /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..b7ce7c38 --- /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/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..91fbe015 --- /dev/null +++ b/examples/nginx/single-file/nginx_basic.conf @@ -0,0 +1,87 @@ +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..b8bf97c7 --- /dev/null +++ b/examples/nginx/single-file/nginx_with_vouch.conf @@ -0,0 +1,97 @@ +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; + } +} \ No newline at end of file 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..d257661e --- /dev/null +++ b/examples/nginx/single-file/nginx_with_vouch_single_server.conf @@ -0,0 +1,97 @@ +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 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 / { + # 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; + } +} \ No newline at end of file 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..976222c2 --- /dev/null +++ b/examples/nginx/single-file/nginx_with_vouch_ssl.conf @@ -0,0 +1,100 @@ +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; + } +} \ No newline at end of file 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..2824f748 --- /dev/null +++ b/examples/startup/systemd/vouch-proxy.service @@ -0,0 +1,16 @@ +[Unit] +Description=Vouch Proxy +After=vouch-proxy.service + +[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/handlers/adfs/adfs.go b/handlers/adfs/adfs.go new file mode 100644 index 00000000..057ebfcd --- /dev/null +++ b/handlers/adfs/adfs.go @@ -0,0 +1,126 @@ +/* + +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/ioutil" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" +) + +// 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) (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.RedirectURL) + 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, _ := ioutil.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/handlers/auth.go b/handlers/auth.go new file mode 100644 index 00000000..d58073a1 --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,160 @@ +/* + +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/structs" +) + +// CallbackHandler /auth +// - validate info from oauth provider (Google, GitHub, OIDC, etc) +// - 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("/auth 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("/auth Invalid session state: stored %s, returned %s", session.Values["state"], queryState) + renderIndex(w, "/auth Invalid session state.") + return + } + + errorState := r.URL.Query().Get("error") + if errorState != "" { + errorDescription := r.URL.Query().Get("error_description") + log.Warn("/auth Error state: ", errorState, ", Error description: ", errorDescription) + w.WriteHeader(http.StatusForbidden) + renderIndex(w, "FORBIDDEN: "+errorDescription) + return + } + + user := structs.User{} + customClaims := structs.CustomClaims{} + ptokens := structs.PTokens{} + + if err := getUserInfo(r, &user, &customClaims, &ptokens); err != nil { + log.Error(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Debugf("/auth Claims from userinfo: %+v", customClaims) + //getProviderJWT(r, &user) + log.Debug("/auth CallbackHandler") + log.Debugf("/auth %+v", user) + + if ok, err := verifyUser(user); !ok { + log.Error(err) + renderIndex(w, fmt.Sprintf("/auth User is not authorized. %s Please try again.", err)) + return + } + + // SUCCESS!! they are authorized + + // issue the jwt + tokenstring := jwtmanager.CreateUserTokenString(user, customClaims, ptokens) + 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 + if err = session.Save(r, w); err != nil { + log.Error(err) + } + + redirect302(w, r, requestedURL) + return + } + // otherwise serve an html page + 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) + + // regexWhiteList + case len(cfg.CompiledRegexWhiteList) != 0: + for _, wl := range cfg.CompiledRegexWhiteList { + log.Debugf("Checking claim: '%v' against regex: '%v'", user.Username, wl) + if wl.MatchString(user.Username) { + log.Debugf("VerifyUser: Success! found user.Username in regexWhiteList: %s", user.Username) + return true, nil + } + } + return false, fmt.Errorf("VerifyUser: user.Username not found in regexWhiteList: %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 "+cfg.Branding.FullName+" managed domain", user.Email) + return true, nil + } + return false, fmt.Errorf("verifyUser: Email %s is not within a "+cfg.Branding.FullName+" managed domain", user.Email) + + // 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) error { + return provider.GetUserInfo(r, user, customClaims, ptokens) +} diff --git a/handlers/common/common.go b/handlers/common/common.go new file mode 100644 index 00000000..c0e5f062 --- /dev/null +++ b/handlers/common/common.go @@ -0,0 +1,78 @@ +/* + +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" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +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, setpid bool) (*http.Client, *oauth2.Token, error) { + configure() + providerToken, err := cfg.OAuthClient.Exchange(context.TODO(), r.URL.Query().Get("code")) + if err != nil { + return nil, nil, err + } + ptokens.PAccessToken = providerToken.AccessToken + + if setpid { + if providerToken.Extra("id_token") != nil { + // Certain providers (eg. gitea) don't provide an id_token + // and it's not neccessary 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: %+v", ptokens) + + 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 == false { + delete(m, k) + } + } + customClaims.Claims = m + return nil +} diff --git a/handlers/github/github.go b/handlers/github/github.go new file mode 100644 index 00000000..64b02ca8 --- /dev/null +++ b/handlers/github/github.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 github + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +// Provider provider specific functions +type Provider struct { + PrepareTokensAndClient func(*http.Request, *structs.PTokens, bool) (*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 +// https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/ +func (me Provider) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens) (rerr error) { + client, ptoken, err := me.PrepareTokensAndClient(r, ptokens, true) + if err != nil { + // http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + log.Errorf("ptoken.AccessToken: %s", ptoken.AccessToken) + userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL + ptoken.AccessToken) + if err != nil { + // http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + defer func() { + if err := userinfo.Body.Close(); err != nil { + rerr = err + } + }() + data, _ := ioutil.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, ptoken) + } else { + isMember, err = getOrgMembershipStateFromGitHub(client, user, org, ptoken) + } + 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, ptoken *oauth2.Token) (isMember bool, rerr error) { + replacements := strings.NewReplacer(":org_id", orgID, ":username", user.Username) + orgMembershipResp, err := client.Get(replacements.Replace(cfg.GenOAuth.UserOrgURL) + ptoken.AccessToken) + 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 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, ptoken *oauth2.Token) (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) + ptoken.AccessToken) + 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, _ := ioutil.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/handlers/github/github_test.go b/handlers/github/github_test.go new file mode 100644 index 00000000..e1a7de37 --- /dev/null +++ b/handlers/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", token) + + 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", token) + + 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", token) + + 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", token) + + assert.Nil(t, err) + assert.False(t, isMember) + + expectedOrgMembershipURL := "https://api.github.com/orgs/myorg/members/" + user.Username + "?access_token=" + token.AccessToken + 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", token) + + assert.Nil(t, err) + assert.True(t, isMember) + + expectedOrgMembershipURL := "https://api.github.com/orgs/myorg/members/" + user.Username + "?access_token=" + token.AccessToken + 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+token.AccessToken), 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) (*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?access_token=" + token.AccessToken + assertURLCalled(t, expectedTeamMembershipURL) +} diff --git a/handlers/google/google.go b/handlers/google/google.go new file mode 100644 index 00000000..1adbc782 --- /dev/null +++ b/handlers/google/google.go @@ -0,0 +1,62 @@ +/* + +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/ioutil" + "net/http" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "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) (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, _ := ioutil.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/handlers/handlers.go b/handlers/handlers.go index d164a066..01bba47e 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -1,519 +1,101 @@ +/* + +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" + "path/filepath" + + "github.com/vouch/vouch-proxy/handlers/adfs" + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/handlers/github" + "github.com/vouch/vouch-proxy/handlers/google" + "github.com/vouch/vouch-proxy/handlers/homeassistant" + "github.com/vouch/vouch-proxy/handlers/indieauth" + "github.com/vouch/vouch-proxy/handlers/nextcloud" + "github.com/vouch/vouch-proxy/handlers/openid" + "github.com/vouch/vouch-proxy/handlers/openstax" - log "github.com/Sirupsen/logrus" + "go.uber.org/zap" - "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" - "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/structs" ) // Index variables passed to index.tmpl type Index struct { - Msg string - TestURL string + Msg string + TestURLs []string + Testing bool } -// AuthError sets the values to return to nginx -type AuthError struct { - Error string - JWT string +// Provider each Provider must support GetuserInfo +type Provider interface { + Configure() + GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens) error } -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)) +const ( + base64Bytes = 32 ) -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) +var ( + indexTemplate *template.Template + sessstore *sessions.CookieStore + log *zap.SugaredLogger + fastlog *zap.Logger + provider Provider +) - 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() + + log.Debugf("handlers.Configure() attempting to parse templates with cfg.RootDir: %s", cfg.RootDir) + indexTemplate = template.Must(template.ParseFiles(filepath.Join(cfg.RootDir, "templates/index.tmpl"))) + + provider = getProvider() + provider.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.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{} + 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..05c14458 --- /dev/null +++ b/handlers/handlers_test.go @@ -0,0 +1,212 @@ +/* + +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" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/vouch/vouch-proxy/pkg/cookie" + + "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/jwtmanager" + "github.com/vouch/vouch-proxy/pkg/structs" + "golang.org/x/oauth2" +) + +var ( + token = &oauth2.Token{AccessToken: "123"} +) + +func setUp(configFile string) { + os.Setenv("VOUCH_CONFIG", filepath.Join(os.Getenv("VOUCH_ROOT"), configFile)) + cfg.InitForTestPurposes() + + // cfg.Cfg.AllowAllUsers = false + // cfg.Cfg.WhiteList = make([]string, 0) + // cfg.Cfg.TeamWhiteList = make([]string, 0) + // cfg.Cfg.Domains = []string{"domain1"} + + Configure() + + domains.Configure() + jwtmanager.Configure() + cookie.Configure() + +} + +// init() for TestValidateRequestHandlerWithGroupClaims +// func init() { +// cfg.RootDir = "../" +// cfg.InitForTestPurposesWithPath("../config/test_config.yml") +// Init() +// } + +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{ + // 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", + } + user := &structs.User{Username: "testuser", Email: "test@example.com", Name: "Test Name"} + userTokenString := jwtmanager.CreateUserTokenString(*user, customClaims, tokens) + + 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: userTokenString, + 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): []string{}, + strings.ToLower(booleanHeader): []string{}, + strings.ToLower(familyNameHeader): []string{}, + strings.ToLower(favoriteColorHeader): []string{}, + } + + 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): []string{"\"Website Users\",\"Test Group\""}, + strings.ToLower(booleanHeader): []string{"true"}, + strings.ToLower(familyNameHeader): []string{"Tester"}, + strings.ToLower(favoriteColorHeader): []string{"blue"}, + } + assert.Equal(t, expectedCustomClaimHeaders, customClaimHeaders) +} + +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 TestVerifyUserPositiveUserInRegexWhiteList(t *testing.T) { + setUp("/config/testing/handler_regexwhitelist.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) +} diff --git a/handlers/healthcheck.go b/handlers/healthcheck.go new file mode 100644 index 00000000..ca9050f0 --- /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/homeassistant/homeassistant.go b/handlers/homeassistant/homeassistant.go new file mode 100644 index 00000000..4f7a2833 --- /dev/null +++ b/handlers/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" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "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 +// 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) (rerr error) { + _, providerToken, err := common.PrepareTokensAndClient(r, ptokens, false) + 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/handlers/indieauth/indieauth.go b/handlers/indieauth/indieauth.go new file mode 100644 index 00000000..397a13fb --- /dev/null +++ b/handlers/indieauth/indieauth.go @@ -0,0 +1,107 @@ +/* + +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/ioutil" + "mime/multipart" + "net/http" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "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) (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, _ := ioutil.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/handlers/login.go b/handlers/login.go new file mode 100644 index 00000000..664de344 --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,193 @@ +/* + +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/theckman/go-securerandom" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/domains" + "golang.org/x/oauth2" +) + +var errTooManyRedirects = errors.New("too many redirects for requested URL") + +// 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 + 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 { + 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 + + log.Debugf("saving session with failcount %d", failcount) + if err = session.Save(r, w); err != nil { + log.Error(err) + } + + if failcount > 2 { + var vouchError = r.URL.Query().Get("error") + error400(w, r, fmt.Errorf("/login %w for %s - %s", errTooManyRedirects, requestedURL, vouchError)) + return + } + + // SUCCESS + // bounce to oauth provider for login + var lURL = loginURL(r, state) + log.Debugf("redirecting to oauthURL %s", lURL) + redirect302(w, r, lURL) +} + +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:"} +) + +func getValidRequestedURL(r *http.Request) (string, error) { + urlparam := r.URL.Query().Get("url") + + if urlparam == "" { + return "", errNoURL + } + if !strings.HasPrefix(strings.ToLower(urlparam), "http://") && !strings.HasPrefix(strings.ToLower(urlparam), "https://") { + return "", errURLNotHTTP + } + u, err := url.Parse(urlparam) + if err != nil { + return "", fmt.Errorf("won't parse: %w %s", errInvalidURL, err) + } + + _, err = url.ParseQuery(u.RawQuery) + if err != nil { + return "", fmt.Errorf("query string won't parse: %w %s", errInvalidURL, err) + } + + 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 == "" { + if cfg.Cfg.Cookie.Domain == "" || !strings.Contains(hostname, cfg.Cfg.Cookie.Domain) { + 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 will not be visible to https)", errInvalidURL, cfg.Branding.LCName, cfg.Cfg.Cookie.Secure) + } + + // and irregardless cookies placed from https are not able to be seen by http + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Secure + if u.Scheme != r.URL.Scheme { + log.Warnf("the requested destination URL %s is %s but %s is running under %s, this may mean the jwt/cookie cannot be seen in some browsers", u, u.Scheme, cfg.Branding.FullName, r.URL.Scheme) + } + + return urlparam, nil +} + +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 lurl = "" + + if cfg.GenOAuth.Provider == cfg.Providers.IndieAuth { + lurl = cfg.OAuthClient.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type", "id")) + } else if cfg.GenOAuth.Provider == cfg.Providers.ADFS { + lurl = cfg.OAuthClient.AuthCodeURL(state, cfg.OAuthopts) + } else { + domain := domains.Matches(r.Host) + log.Debugf("looking for callback_url matching %v", domain) + for i, v := range cfg.GenOAuth.RedirectURLs { + if strings.Contains(v, domain) { + log.Debugf("redirect value matched at [%d]=%v", i, v) + cfg.OAuthClient.RedirectURL = v + break + } + } + if cfg.OAuthopts != nil { + lurl = cfg.OAuthClient.AuthCodeURL(state, cfg.OAuthopts) + } else { + lurl = cfg.OAuthClient.AuthCodeURL(state) + } + } + // log.Debugf("loginURL %s", url) + return lurl +} + +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 +} diff --git a/handlers/login_test.go b/handlers/login_test.go new file mode 100644 index 00000000..831ba155 --- /dev/null +++ b/handlers/login_test.go @@ -0,0 +1,53 @@ +/* + +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/url" + "testing" +) + +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}, + {"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", "http://somewherelse.com/", "", true}, + {"should warn", "https://example.com/", "https://example.com/", false}, + {"should be fine", "http://example.com/", "http://example.com/", false}, + + // TODO: Add test cases. + } + 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) + } + }) + } +} diff --git a/handlers/logout.go b/handlers/logout.go new file mode 100644 index 00000000..25d4519e --- /dev/null +++ b/handlers/logout.go @@ -0,0 +1,53 @@ +/* + +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" +) + +var errUnauthRedirURL = fmt.Errorf("/logout The requested url is not present in `%s.post_logout_redirect_uris`", cfg.Branding.LCName) + +// LogoutHandler /logout +// 302 redirect to the provider +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + log.Debug("/logout") + + cookie.ClearCookie(w, r) + log.Debug("/logout deleting session") + sessstore.MaxAge(-1) + session, err := sessstore.Get(r, cfg.Cfg.Session.Name) + if err != nil { + log.Error(err) + } + if err = session.Save(r, w); err != nil { + log.Error(err) + } + sessstore.MaxAge(300) + + var requestedURL = r.URL.Query().Get("url") + if requestedURL != "" { + for _, allowed := range cfg.Cfg.LogoutRedirectURLs { + if allowed == requestedURL { + log.Debugf("/logout found ") + redirect302(w, r, allowed) + return + } + } + error400(w, r, fmt.Errorf("%w: %s", errUnauthRedirURL, requestedURL)) + return + } + 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..11eab4de --- /dev/null +++ b/handlers/logout_test.go @@ -0,0 +1,47 @@ +/* + +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" +) + +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) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() = %v, want %v", rr.Code, tt.wantcode) + } + }) + } +} diff --git a/handlers/nextcloud/nextcloud.go b/handlers/nextcloud/nextcloud.go new file mode 100644 index 00000000..6316c0ec --- /dev/null +++ b/handlers/nextcloud/nextcloud.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 nextcloud + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "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) (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, _ := ioutil.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/handlers/openid/openid.go b/handlers/openid/openid.go new file mode 100644 index 00000000..4d6f22f8 --- /dev/null +++ b/handlers/openid/openid.go @@ -0,0 +1,61 @@ +/* + +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/ioutil" + "net/http" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "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) (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, _ := ioutil.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/handlers/openstax/openstax.go b/handlers/openstax/openstax.go new file mode 100644 index 00000000..b8d671f1 --- /dev/null +++ b/handlers/openstax/openstax.go @@ -0,0 +1,68 @@ +/* + +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/ioutil" + "net/http" + + "github.com/vouch/vouch-proxy/handlers/common" + "github.com/vouch/vouch-proxy/pkg/cfg" + "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) (rerr error) { + client, _, err := common.PrepareTokensAndClient(r, ptokens, false) + 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, _ := ioutil.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/handlers/responses.go b/handlers/responses.go new file mode 100644 index 00000000..1d568f36 --- /dev/null +++ b/handlers/responses.go @@ -0,0 +1,62 @@ +/* + +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" +) + +func renderIndex(w http.ResponseWriter, msg string) { + if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURLs: cfg.Cfg.TestURLs, Testing: cfg.Cfg.Testing}); err != nil { + log.Error(err) + } +} +func ok200(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("200 OK\n")) + if err != nil { + log.Error(err) + } +} + +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) +} + +// 400 Bad Request +// returned when the requesed url param for /login or /logout is bd +func error400(w http.ResponseWriter, r *http.Request, e error) { + log.Error(e) + cookie.ClearCookie(w, r) + w.Header().Set("X-Vouch-Error", e.Error()) + http.Error(w, e.Error(), http.StatusBadRequest) +} + +// 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, e error) { + log.Error(e) + cookie.ClearCookie(w, r) + w.Header().Set("X-Vouch-Error", e.Error()) + http.Error(w, e.Error(), http.StatusUnauthorized) +} + +func error401na(w http.ResponseWriter, r *http.Request) { + error401(w, r, fmt.Errorf("not authorized")) +} diff --git a/handlers/validate.go b/handlers/validate.go new file mode 100644 index 00000000..9569f0ba --- /dev/null +++ b/handlers/validate.go @@ -0,0 +1,184 @@ +/* + +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" + + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/cookie" + "github.com/vouch/vouch-proxy/pkg/jwtmanager" + "go.uber.org/zap" +) + +var ( + errNoJWT = errors.New("no jwt found in request") + errNoUser = errors.New("no User found in jwt") +) + +// ValidateRequestHandler /validate +// TODO this should use the handler interface +func ValidateRequestHandler(w http.ResponseWriter, r *http.Request) { + fastlog.Debug("/validate") + + jwt := findJWT(r) + if jwt == "" { + send401or200PublicAccess(w, r, errNoJWT) + return + } + + claims, err := claimsFromJWT(jwt) + if err != nil { + send401or200PublicAccess(w, r, err) + return + } + + if claims.Username == "" { + send401or200PublicAccess(w, r, errNoUser) + return + } + + if !cfg.Cfg.AllowAllUsers { + if !jwtmanager.SiteInClaims(r.Host, &claims) { + 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 { + renderIndex(w, "user authorized "+claims.Username) + } else { + ok200(w, r) + } + + // TODO + // parse the jwt and see if the claim is valid for the domain + +} + +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, "") + ok200(w, r) + return + } + error401(w, r, e) +} + +// 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 { + log.Debugf("jwt from cookie: %s", jwt) + 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) (jwtmanager.VouchClaims, error) { + var claims jwtmanager.VouchClaims + + 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.VouchClaims{} { + return claims, err + } + log.Debugf("JWT Claims: %+v", claims) + return claims, nil +} 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..dd0585f9 100644 --- a/main.go +++ b/main.go @@ -1,60 +1,173 @@ +/* + +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 ( + "errors" + "flag" + "log" + "net" "net/http" + "os" + "path/filepath" "strconv" "time" - log "github.com/Sirupsen/logrus" + "github.com/gorilla/mux" + "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/response" + "github.com/vouch/vouch-proxy/pkg/timelog" +) - "github.com/bnfinet/lasso/handlers" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/timelog" - tran "github.com/bnfinet/lasso/pkg/transciever" +// version and semver get overwritten by build with +// 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" + staticDir = "/static/" + logger *zap.SugaredLogger + fastlog *zap.Logger + help = flag.Bool("help", false, "show usage") ) +// 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) + } + + cfg.Configure() + healthcheck.CheckAndExitIfIsHealthCheck() + + cfg.TestConfiguration() + + logger = cfg.Logging.Logger + fastlog = cfg.Logging.FastLogger + + domains.Configure() + jwtmanager.Configure() + cookie.Configure() + handlers.Configure() + timelog.Configure() + response.Configure() +} + func main() { - log.Info("starting lasso") - mux := http.NewServeMux() - // router := mux.NewRouter() - // router.HandleFunc("/", handlers.IndexHandler) + configure() + var listen = cfg.Cfg.Listen + ":" + strconv.Itoa(cfg.Cfg.Port) + checkTCPPortAvailable(listen) + + logger.Infow("starting "+cfg.Branding.FullName, + // "semver": semver, + "version", version, + "buildtime", builddt, + "buildhost", host, + "branch", branch, + "semver", semver, + "listen", listen, + "oauth.provider", cfg.GenOAuth.Provider) + + muxR := mux.NewRouter() authH := http.HandlerFunc(handlers.ValidateRequestHandler) - mux.HandleFunc("/validate", timelog.TimeLog(authH)) - // mux.HandleFunc("/validate", handlers.ValidateRequestHandler) + muxR.HandleFunc("/validate", timelog.TimeLog(authH)) + muxR.HandleFunc("/_external-auth-{id}", timelog.TimeLog(authH)) loginH := http.HandlerFunc(handlers.LoginHandler) - mux.HandleFunc("/login", timelog.TimeLog(loginH)) + muxR.HandleFunc("/login", timelog.TimeLog(loginH)) logoutH := http.HandlerFunc(handlers.LogoutHandler) - mux.HandleFunc("/logout", timelog.TimeLog(logoutH)) + muxR.HandleFunc("/logout", timelog.TimeLog(logoutH)) callH := http.HandlerFunc(handlers.CallbackHandler) - mux.HandleFunc("/auth", timelog.TimeLog(callH)) - - // router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) - mux.Handle("/static", http.FileServer(http.Dir("./static"))) - - mux.Handle("/ws", tran.WS) - - // socketio := tran.NewServer() - // mux.Handle("/socket.io/", cors.AllowAll(socketio)) - // http.Handle("/socket.io/", tran.Server) - - var listen = cfg.Cfg.Listen + ":" + strconv.Itoa(cfg.Cfg.Port) - log.Infof("running lasso on %s", listen) + muxR.HandleFunc("/auth", timelog.TimeLog(callH)) + + healthH := http.HandlerFunc(handlers.HealthcheckHandler) + muxR.HandleFunc("/healthcheck", timelog.TimeLog(healthH)) + + // setup static + sPath, err := filepath.Abs(cfg.RootDir + staticDir) + if fastlog.Core().Enabled(zap.DebugLevel) { + if err != nil { + logger.Errorf("couldn't find static assets at %s", sPath) + } + logger.Debugf("serving static files from %s", sPath) + } + // https://golangcode.com/serve-static-assets-using-the-mux-router/ + muxR.PathPrefix(staticDir).Handler(http.StripPrefix(staticDir, http.FileServer(http.Dir(sPath)))) srv := &http.Server{ - Handler: mux, + Handler: muxR, Addr: listen, // Good practice: enforce timeouts for servers you create! WriteTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second, + ErrorLog: log.New(&fwdToZapWriter{fastlog}, "", 0), } - log.Fatal(srv.ListenAndServe()) + logger.Fatal(srv.ListenAndServe()) } + +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) + } +} diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go index 2c2e4a23..62805a98 100644 --- a/pkg/cfg/cfg.go +++ b/pkg/cfg/cfg.go @@ -1,21 +1,47 @@ +/* + +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 ( + "errors" "flag" + "fmt" + "net/http" "os" + "path/filepath" + "strings" + "regexp" - log "github.com/Sirupsen/logrus" + "github.com/mitchellh/mapstructure" + + "golang.org/x/oauth2" "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 { +// Config vouch jwt cookie configuration +type Config struct { + LogLevel string `mapstructure:"logLevel"` + Listen string `mapstructure:"listen"` + Port int `mapstructure:"port"` + Domains []string `mapstructure:"domains"` + WhiteList []string `mapstructure:"whitelist"` + RegexWhiteList []string `mapstructure:"regexWhiteList"` + TeamWhiteList []string `mapstructure:"teamWhitelist"` + AllowAllUsers bool `mapstructure:"allowAllUsers"` + PublicAccess bool `mapstructure:"publicAccess"` + JWT struct { MaxAge int `mapstructure:"maxAge"` Issuer string `mapstructure:"issuer"` Secret string `mapstructure:"secret"` @@ -23,59 +49,298 @@ type CfgT struct { } 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"` + 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"` + Key string `mapstructure:"key"` + } + TestURL string `mapstructure:"test_url"` + TestURLs []string `mapstructure:"test_urls"` + Testing bool `mapstructure:"testing"` + LogoutRedirectURLs []string `mapstructure:"post_logout_redirect_uris"` +} + +// oauth config items endoint for access +type oauthConfig struct { + Provider string `mapstructure:"provider"` + 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"` + RedirectURLs []string `mapstructure:"callback_urls"` + Scopes []string `mapstructure:"scopes"` + UserInfoURL string `mapstructure:"user_info_url"` + UserTeamURL string `mapstructure:"user_team_url"` + UserOrgURL string `mapstructure:"user_org_url"` + PreferredDomain string `mapstructre:"preferredDomain"` +} + +// OAuthProviders holds the stings for +type OAuthProviders struct { + Google string + GitHub string + IndieAuth string + ADFS string + OIDC string + HomeAssistant string + OpenStax string + Nextcloud string +} + +type branding struct { + LCName string // lower case + UCName string // upper case + CcName string // camel case + FullName string // Vouch Proxy + OldLCName string // lasso + URL string // https://github.com/vouch/vouch-proxy +} + +var ( + // Branding that's our name + Branding = branding{"vouch", "VOUCH", "Vouch", "Vouch Proxy", "lasso", "https://github.com/vouch/vouch-proxy"} + + // GenOAuth exported OAuth config variable + // TODO: I think GenOAuth and OAuthConfig can be combined! + // perhaps by https://golang.org/doc/effective_go.html#embedding + 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", + OIDC: "oidc", + HomeAssistant: "homeassistant", + OpenStax: "openstax", + Nextcloud: "nextcloud", + } + + // RequiredOptions must have these fields set for minimum viable config + RequiredOptions = []string{"oauth.provider", "oauth.client_id"} + + // RootDir is where Vouch Proxy looks for ./config/config.yml, ./data, ./static and ./templates + RootDir string + + 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)"), + } + + // Cfg the main exported config variable + Cfg = &Config{} + // IsHealthCheck see main.go + IsHealthCheck = false + // CompiledRegexWhiteList see auth.go + CompiledRegexWhiteList []*regexp.Regexp +) + +type cmdLineFlags struct { + IsHealthCheck *bool + port *int + configFile *string + logLevel *zapcore.Level + logTest *bool +} + +const ( + // for a Base64 string we need 44 characters to get 32bytes (6 bits per char) + minBase64Length = 44 + base64Bytes = 32 +) + +// Configure called at the very top of main() +func Configure() { + + Logging.configureFromCmdline() + + setRootDir() + secretFile = filepath.Join(RootDir, "config/secret") + + // bail if we're testing + if flag.Lookup("test.v") != nil { + Logging.setLogLevel(zap.DebugLevel) + log.Debug("`go test` detected, not loading regular config") + return + } + + parseConfig() + Logging.configure() + setDefaults() + cleanClaimsHeaders() + if *CmdLine.port != -1 { + Cfg.Port = *CmdLine.port + } + +} + +// TestConfiguration Confirm the Configuration is valid +func TestConfiguration() { + if Cfg.Testing { + // Logging.setLogLevel(zap.DebugLevel) + Logging.setDevelopmentLogger() + } + + errT := basicTest() + if errT != nil { + log.Panic(errT) + } + + log.Debugf("viper settings %+v", viper.AllSettings()) +} + +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) + log.Debugf("cfg.RootDir: %s", RootDir) } - TestURL string `mapstructure:"test_url"` } -// Cfg the main exported config variable -var Cfg CfgT +// InitForTestPurposes is called by most *_testing.go files in Vouch Proxy +func InitForTestPurposes() { + InitForTestPurposesWithProvider("") +} -// V viper object -// var V viper +// InitForTestPurposesWithProvider just for testing +func InitForTestPurposesWithProvider(provider string) { + Cfg = &Config{} // clear it out since we're called multiple times from subsequent tests + 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() + parseConfig() + setDefaults() + // setDevelopmentLogger() -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") + // Needed to override the provider, which is otherwise set via yml + if provider != "" { + GenOAuth.Provider = provider + setProviderDefaults() } - log.Debug(viper.AllSettings()) + cleanClaimsHeaders() + } -// ParseConfig parse the config file -func ParseConfig() { - log.Info("opening config") - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(os.Getenv("LASSO_ROOT") + "config") +// parseConfig parse the config file +func parseConfig() { + 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) + log.Panic(err) } - 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)) + + 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) + } + + if len(Cfg.Domains) == 0 { + // then lets check for "lasso" + var oldConfig = &Config{} + if err = UnmarshalKey(Branding.OldLCName, &oldConfig); err != nil { + log.Error(err) + } + + if len(oldConfig.Domains) != 0 { + log.Errorf(` + +IMPORTANT! + +please update your config file to change '%s:' to '%s:' as per %s + `, Branding.OldLCName, Branding.LCName, Branding.URL) + Cfg = oldConfig + } + } + + // don't log the secret! + // log.Debugf("secret: %s", string(Cfg.JWT.Secret)) +} + +// use viper and mapstructure check to see if +// https://pkg.go.dev/github.com/spf13/viper@v1.6.3?tab=doc#Unmarshal +// https://pkg.go.dev/github.com/mitchellh/mapstructure?tab=doc#DecoderConfig +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 +352,230 @@ 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 + } + + for _, opt := range RequiredOptions { + if !viper.IsSet(opt) { + return errors.New("configuration error: required configuration option " + opt + " is not set") + } + } + // Domains is required _unless_ Cfg.AllowAllUsers is set + if !viper.IsSet(Branding.LCName+".allowAllUsers") && !viper.IsSet(Branding.LCName+".domains") { + 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)) + 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) + } + + 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) + } + // if using regexWhiteList, compile regex statements, and store them in cfg.CompiledRegexWhiteList + if len(Cfg.RegexWhiteList) != 0 { + for i, wl := range Cfg.RegexWhiteList { + //generate regex array + reWhiteList, reWhiteListErr := regexp.Compile(wl) + if reWhiteListErr != nil { + log.Fatalf("Uncompilable regex parameter: '%v'", wl) + } + CompiledRegexWhiteList = append(CompiledRegexWhiteList, reWhiteList) + log.Debugf("Compiled regex parameter '%v'", CompiledRegexWhiteList[i]) + } + log.Debugf("compiled regex array %v", CompiledRegexWhiteList) + } + return nil +} + +// setDefaults set default options for most items +func setDefaults() { + + // this should really be done by Viper up in parseConfig but.. + // 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-"+Branding.CcName+"-Token") + // viper.SetDefault("Headers.Redirect", "X-"+Branding.CcName+"-Requested-URI") + // viper.SetDefault("Cookie.Name", "Vouch") + + // network defaults + if !viper.IsSet(Branding.LCName + ".listen") { + Cfg.Listen = "0.0.0.0" + } + + if !viper.IsSet(Branding.LCName + ".port") { + Cfg.Port = 9090 + } + + // bare minimum for healthcheck acheived + if *CmdLine.IsHealthCheck { + return + } + + if !viper.IsSet(Branding.LCName + ".allowAllUsers") { + Cfg.AllowAllUsers = false + } + if !viper.IsSet(Branding.LCName + ".publicAccess") { + Cfg.PublicAccess = false + } + + // jwt defaults + if !viper.IsSet(Branding.LCName + ".jwt.secret") { + Cfg.JWT.Secret = getOrGenerateJWTSecret() + } + if !viper.IsSet(Branding.LCName + ".jwt.issuer") { + Cfg.JWT.Issuer = Branding.CcName + } + if !viper.IsSet(Branding.LCName + ".jwt.maxAge") { + Cfg.JWT.MaxAge = 240 + } + if !viper.IsSet(Branding.LCName + ".jwt.compress") { + Cfg.JWT.Compress = true + } + + // cookie defaults + if !viper.IsSet(Branding.LCName + ".cookie.name") { + Cfg.Cookie.Name = Branding.CcName + "Cookie" + } + if !viper.IsSet(Branding.LCName + ".cookie.secure") { + Cfg.Cookie.Secure = false + } + if !viper.IsSet(Branding.LCName + ".cookie.httpOnly") { + Cfg.Cookie.HTTPOnly = true + } + if !viper.IsSet(Branding.LCName + ".cookie.maxAge") { + Cfg.Cookie.MaxAge = Cfg.JWT.MaxAge + } else { + // it is set! is it bigger than jwt.maxage? + 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.jwt") { + Cfg.Headers.JWT = "X-" + Branding.CcName + "-Token" + } + if !viper.IsSet(Branding.LCName + ".headers.querystring") { + Cfg.Headers.QueryString = "access_token" + } + if !viper.IsSet(Branding.LCName + ".headers.redirect") { + Cfg.Headers.Redirect = "X-" + Branding.CcName + "-Requested-URI" + } + if !viper.IsSet(Branding.LCName + ".headers.user") { + Cfg.Headers.User = "X-" + Branding.CcName + "-User" + } + if !viper.IsSet(Branding.LCName + ".headers.success") { + Cfg.Headers.Success = "X-" + Branding.CcName + "-Success" + } + if !viper.IsSet(Branding.LCName + ".headers.claimheader") { + Cfg.Headers.ClaimHeader = "X-" + Branding.CcName + "-IdP-Claims-" + } + + // session + if !viper.IsSet(Branding.LCName + ".session.name") { + Cfg.Session.Name = Branding.CcName + "Session" + } + if !viper.IsSet(Branding.LCName + ".session.key") { + log.Warn("generating random session.key") + rstr, err := securerandom.Base64OfBytes(base64Bytes) + if err != nil { + log.Fatal(err) + } + Cfg.Session.Key = rstr + } + + // testing convenience variable + if !viper.IsSet(Branding.LCName + ".testing") { + Cfg.Testing = false + } + if viper.IsSet(Branding.LCName + ".test_url") { + Cfg.TestURLs = append(Cfg.TestURLs, Cfg.TestURL) + } + + // OAuth defaults and client configuration + err := UnmarshalKey("oauth", &GenOAuth) + if err == nil { + setProviderDefaults() + } +} + +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 popultate 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 +} diff --git a/pkg/cfg/cfg_test.go b/pkg/cfg/cfg_test.go index 86fb5ead..571844aa 100644 --- a/pkg/cfg/cfg_test.go +++ b/pkg/cfg/cfg_test.go @@ -1,30 +1,72 @@ +/* + +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 ( "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 -) +func TestConfigParsing(t *testing.T) { + InitForTestPurposes() + Configure() -func init() { - // log.SetLevel(log.DebugLevel) -} + // UnmarshalKey(Branding.LCName, &cfg) + log.Debugf("cfgPort %d", Cfg.Port) + log.Debugf("cfgDomains %s", Cfg.Domains[0]) -func TestConfigParsing(t *testing.T) { + assert.Equal(t, Cfg.Port, 9090) + + assert.NotEmpty(t, Cfg.JWT.MaxAge) + +} - UnmarshalKey("lasso", &cfg) - log.Debugf("cfgPort %d", cfg.Port) - log.Debugf("cfgDomains %s", cfg.Domains[0]) +func TestSetGitHubDefaults(t *testing.T) { + InitForTestPurposesWithProvider("github") + assert.Equal(t, []string{"read:user"}, GenOAuth.Scopes) +} - assert.Equal(t, cfg.Port, 9090) - assert.Equal(t, cfg.Cookie.Name, "bnfSSO") +func TestSetGitHubDefaultsWithTeamWhitelist(t *testing.T) { + InitForTestPurposesWithProvider("github") + Cfg.TeamWhiteList = append(Cfg.TeamWhiteList, "org/team") + GenOAuth.Scopes = []string{} - assert.NotEmpty(t, cfg.JWT.MaxAge) + setDefaultsGitHub() + assert.Contains(t, GenOAuth.Scopes, "read:user") + assert.Contains(t, GenOAuth.Scopes, "read:org") +} +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) + } + }) + } } diff --git a/pkg/cfg/jwt.go b/pkg/cfg/jwt.go new file mode 100644 index 00000000..77e5c168 --- /dev/null +++ b/pkg/cfg/jwt.go @@ -0,0 +1,42 @@ +/* + +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 ( + "io/ioutil" + + securerandom "github.com/theckman/go-securerandom" +) + +func getOrGenerateJWTSecret() string { + b, err := ioutil.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 = ioutil.WriteFile(secretFile, b, 0600) + if err != nil { + log.Debug(err) + } + } + return string(b) +} diff --git a/pkg/cfg/logging.go b/pkg/cfg/logging.go new file mode 100644 index 00000000..9c368e40 --- /dev/null +++ b/pkg/cfg/logging.go @@ -0,0 +1,147 @@ +/* + +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" + "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 = fmt.Sprintf("%s", Logging.DefaultLogLevel) + } + + 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..8b2d2a52 --- /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..95a5b7b2 --- /dev/null +++ b/pkg/cfg/oauth.go @@ -0,0 +1,166 @@ +/* + +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 ( + "errors" + "fmt" + "strings" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" +) + +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.OIDC && + GenOAuth.Provider != Providers.OpenStax && + GenOAuth.Provider != Providers.Nextcloud { + return errors.New("configuration error: Unkown 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.UserInfoURL == "": + // everyone except IndieAuth, Google and ADFS has an userInfoURL + return errors.New("configuration error: oauth.user_info_url not found") + } + + 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 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 { + // IndieAuth, 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, + } + if GenOAuth.PreferredDomain != "" { + log.Infof("setting Google OAuth preferred login domain param 'hd' to %s", GenOAuth.PreferredDomain) + OAuthopts = oauth2.SetAuthURLParam("hd", GenOAuth.PreferredDomain) + } +} + +func setDefaultsADFS() { + log.Info("configuring ADFS OAuth") + OAuthopts = oauth2.SetAuthURLParam("resource", GenOAuth.RedirectURL) // Needed or all claims won't be included +} + +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?access_token=" + } + if GenOAuth.UserTeamURL == "" { + GenOAuth.UserTeamURL = "https://api.github.com/orgs/:org_id/teams/:team_slug/memberships/:username?access_token=" + } + if GenOAuth.UserOrgURL == "" { + GenOAuth.UserOrgURL = "https://api.github.com/orgs/:org_id/members/:username?access_token=" + } + 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") + } + } +} + +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 +} diff --git a/pkg/cfg/oauth_test.go b/pkg/cfg/oauth_test.go new file mode 100644 index 00000000..f86b07ec --- /dev/null +++ b/pkg/cfg/oauth_test.go @@ -0,0 +1,42 @@ +/* + +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" + "path/filepath" + "testing" +) + +func setUp(configFile string) { + os.Setenv("VOUCH_CONFIG", filepath.Join(os.Getenv("VOUCH_ROOT"), configFile)) + InitForTestPurposes() +} + +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) + } + }) + } +} 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..1db6553d 100644 --- a/pkg/cookie/cookie.go +++ b/pkg/cookie/cookie.go @@ -1,30 +1,55 @@ +/* + +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" + "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 + +var log *zap.SugaredLogger + +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger +} // 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) { + cookieName := cfg.Cfg.Cookie.Name // 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) + } + sameSite := SameSite() + + cookie := http.Cookie{ Name: cfg.Cfg.Cookie.Name, Value: val, Path: "/", @@ -32,23 +57,154 @@ 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()) + cookie.Value = "" + emptyCookieSize := 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) + cookieParts := splitCookie(val, maxCookieSize-emptyCookieSize) + for i, cookiePart := range cookieParts { + // Cookies are named 1of3, 2of3, 3of3 + cookieName = fmt.Sprintf("%s_%dof%d", cfg.Cfg.Cookie.Name, i+1, len(cookieParts)) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: cookiePart, + Path: "/", + Domain: domain, + MaxAge: maxAge, + Secure: cfg.Cfg.Cookie.Secure, + HttpOnly: cfg.Cfg.Cookie.HTTPOnly, + SameSite: sameSite, + }) + } + } else { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: val, + Path: "/", + Domain: domain, + MaxAge: maxAge, + Secure: cfg.Cfg.Cookie.Secure, + HttpOnly: cfg.Cfg.Cookie.HTTPOnly, + SameSite: sameSite, + }) + } } -// 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 + + cookieParts := make([]string, 0) + var numParts = -1 + + var err error + cookies := r.Cookies() + // Get the remaining parts + // search for cookie parts in order + // this is the hotpath 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) + } + 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) + } + 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, + MaxAge: -1, + 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 == false { + 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..93222c02 --- /dev/null +++ b/pkg/cookie/cookie_test.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 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) + r := &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), + }, + }, + } + r.Cookies() + s, err := Cookie(r) + if err != nil { + t.Error(err) + } + if expectedValue != s { + t.Errorf("expected \"%s\" received \"%s\"", expectedValue, s) + } +} 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..ae215908 100644 --- a/pkg/domains/domains.go +++ b/pkg/domains/domains.go @@ -1,32 +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 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 +// Matches return the first match of the func Matches(s string) string { + 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] + } + for i, v := range cfg.Cfg.Domains { - log.Debugf("array value at [%d]=%v", i, v) - if strings.Contains(s, v) { + 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) +// 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 + } + + match := Matches(split[1]) if match != "" { return true } return false } + +// 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..deb57ffe --- /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..f602edbd --- /dev/null +++ b/pkg/healthcheck/healthcheck.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 healthcheck + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "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.Debug("Invoking healthcheck on URL ", url) + resp, err := http.Get(url) + if err == nil { + body, err := ioutil.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/jwtmanager.go b/pkg/jwtmanager/jwtmanager.go index 1d38d3c7..13943469 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 ( @@ -10,45 +20,64 @@ import ( "strings" "time" - log "github.com/Sirupsen/logrus" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/structs" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" + "go.uber.org/zap" jwt "github.com/dgrijalva/jwt-go" ) // const numSites = 2 -// 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 +// VouchClaims jwt Claims specific to vouch +type VouchClaims struct { + Username string `json:"username"` + Sites []string `json:"sites"` // tempting to make this a map but the array is fewer characters in the jwt + CustomClaims map[string]interface{} + PAccessToken string + PIdToken string jwt.StandardClaims } -// StandardClaims jwt.StandardClaims implimentation +// StandardClaims jwt.StandardClaims implementation var StandardClaims jwt.StandardClaims -// Sites just testing +// CustomClaims implementation +// var CustomClaims map[string]interface{} + +// Sites added to VouchClaims var Sites []string +var log *zap.SugaredLogger -func init() { +// Configure see main.go configure() +func Configure() { + log = cfg.Logging.Logger StandardClaims = jwt.StandardClaims{ Issuer: cfg.Cfg.JWT.Issuer, } - Sites = make([]string, 0) + populateSites() +} +func populateSites() { + Sites = 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]) } } // CreateUserTokenString converts user to signed jwt -func CreateUserTokenString(u structs.User) string { +func CreateUserTokenString(u structs.User, customClaims structs.CustomClaims, ptokens structs.PTokens) string { // User`token` - claims := LassoClaims{ - u.Email, + // u.PrepareUserData() + claims := VouchClaims{ + u.Username, Sites, + customClaims.Claims, + ptokens.PAccessToken, + ptokens.PIdToken, StandardClaims, } @@ -95,7 +124,7 @@ func TokenIsValid(token *jwt.Token, err error) bool { // 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) { return true @@ -113,8 +142,8 @@ func ParseTokenString(tokenString string) (*jwt.Token, error) { log.Debugf("decompressed tokenString %s", tokenString) } - return jwt.ParseWithClaims(tokenString, &LassoClaims{}, func(token *jwt.Token) (interface{}, error) { - // return jwt.ParseWithClaims(tokenString, &LassoClaims{}, func(token *jwt.Token) (interface{}, error) { + 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("HS256") { return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) } @@ -125,24 +154,24 @@ func ParseTokenString(tokenString string) (*jwt.Token, error) { } // SiteInClaims does the claim contain the value? -func SiteInClaims(site string, claims *LassoClaims) bool { +func SiteInClaims(site string, claims *VouchClaims) bool { for _, s := range claims.Sites { if strings.Contains(site, s) { - log.Debugf("evaluating %s contains %s", site, s) + log.Debugf("site %s is found for claims.Site %s", site, s) 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) { +// TODO HERE there's something wrong with claims parsing, probably related to VouchClaims not being a pointer +func PTokenClaims(ptoken *jwt.Token) (VouchClaims, error) { + // func PTokenClaims(ptoken *jwt.Token) (VouchClaims, error) { // return ptoken.Claims, nil - // return ptoken.Claims.(*LassoClaims), nil - ptokenClaims, ok := ptoken.Claims.(*LassoClaims) + // return ptoken.Claims.(*VouchClaims), nil + ptokenClaims, ok := ptoken.Claims.(*VouchClaims) if !ok { log.Debugf("failed claims: %v %v", ptokenClaims, ptoken.Claims) return *ptokenClaims, errors.New("cannot parse claims") @@ -151,17 +180,17 @@ func PTokenClaims(ptoken *jwt.Token) (LassoClaims, error) { return *ptokenClaims, nil } -// PTokenToEmail returns the Email in the validated ptoken -func PTokenToEmail(ptoken *jwt.Token) (string, error) { - return ptoken.Claims.(*LassoClaims).Email, nil +// PTokenToUsername returns the Username in the validated ptoken +func PTokenToUsername(ptoken *jwt.Token) (string, error) { + return ptoken.Claims.(*VouchClaims).Username, nil - // var ptokenClaims LassoClaims + // var ptokenClaims VouchClaims // ptokenClaims, err := PTokenClaims(ptoken) // if err != nil { // log.Error(err) // return "", err // } - // return ptokenClaims.Email, nil + // return ptokenClaims.Username, nil } func decodeAndDecompressTokenString(encgzipss string) string { @@ -170,16 +199,17 @@ func decodeAndDecompressTokenString(encgzipss string) string { // 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) return string(ss) diff --git a/pkg/jwtmanager/jwtmanager_test.go b/pkg/jwtmanager/jwtmanager_test.go index ed08027a..eb844ff3 100644 --- a/pkg/jwtmanager/jwtmanager_test.go +++ b/pkg/jwtmanager/jwtmanager_test.go @@ -1,38 +1,67 @@ +/* + +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" "testing" - "github.com/bnfinet/lasso/pkg/cfg" - "github.com/bnfinet/lasso/pkg/structs" - // log "github.com/Sirupsen/logrus" - log "github.com/Sirupsen/logrus" + "github.com/vouch/vouch-proxy/pkg/cfg" + "github.com/vouch/vouch-proxy/pkg/structs" + "github.com/stretchr/testify/assert" ) 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 VouchClaims - lc LassoClaims + 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) - lc = LassoClaims{ - u1.Email, + cfg.InitForTestPurposes() + Configure() + + lc = VouchClaims{ + u1.Username, Sites, + customClaims.Claims, + t1.PAccessToken, + t1.PIdToken, StandardClaims, } + json.Unmarshal([]byte(claimjson), &customClaims.Claims) } -func TestCreateUserTokenStringAndParseToEmail(t *testing.T) { +func TestCreateUserTokenStringAndParseToUsername(t *testing.T) { - uts := CreateUserTokenString(u1) + uts := CreateUserTokenString(u1, customClaims, t1) assert.NotEmpty(t, uts) utsParsed, err := ParseTokenString(uts) @@ -40,15 +69,14 @@ func TestCreateUserTokenStringAndParseToEmail(t *testing.T) { t.Error(err) } else { log.Debugf("test parsed token string %v", utsParsed) - ptemail, _ := PTokenToEmail(utsParsed) - assert.Equal(t, u1.Email, ptemail) + ptUsername, _ := PTokenToUsername(utsParsed) + assert.Equal(t, u1.Username, ptUsername) } } func TestClaims(t *testing.T) { - cfg.ParseConfig() - + populateSites() 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 +86,10 @@ 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 := CreateUserTokenString(u1, customClaims, t1) utsParsed, _ := ParseTokenString(uts) - assert.True(t, SiteInToken("naga.bnf.net", utsParsed)) + log.Infof("utsParsed: %+v", utsParsed) + log.Infof("Sites: %+v", Sites) + assert.True(t, SiteInToken(cfg.Cfg.Domains[0], utsParsed)) } 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/response/response.go b/pkg/response/response.go new file mode 100644 index 00000000..4c0511db --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,61 @@ +/* + +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 response + +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 log *zap.Logger + +// Configure see main.go configure() +func Configure() { + 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/structs/structs.go b/pkg/structs/structs.go index a11185d6..cec3a700 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -1,16 +1,53 @@ +/* + +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 +// 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 + } } // 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 +61,114 @@ 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 + } } // 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 +176,11 @@ 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 } diff --git a/pkg/timelog/timelog.go b/pkg/timelog/timelog.go index 65ce51dd..b2ea24c2 100644 --- a/pkg/timelog/timelog.go +++ b/pkg/timelog/timelog.go @@ -1,76 +1,73 @@ +/* + +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/cfg" + "github.com/vouch/vouch-proxy/pkg/response" + "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 + response.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 := response.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)) + 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.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 + 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..425efd9a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,26 +1,25 @@ body { - background-image: url("/img/background.png"); - background-color: #cccccc; + background-color: #ffffff; } -h1 { - text-align: center; - /*color: green;*/ - /*text-decoration: overline;*/ - text-shadow: 2px 2px #ff0000; +.top img { + height: 50px; + width: 50px; + margin: 10px; + vertical-align: middle } -button { - background-color: #4CAF50; /* Green */ - border: none; - color: white; - padding: 15px 32px; - text-align: center; +.top span { + margin: 5px; + font-size: 1.5em; +} + +.top a { text-decoration: none; - display: inline-block; - font-size: 16px; - border-radius: 12px; - position: absolute; - top: 45%; - left: 42%; + color: #000000; } + + +.test { + clear: both; +} \ No newline at end of file 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..08ed7947 100644 --- a/templates/index.tmpl +++ b/templates/index.tmpl @@ -3,17 +3,37 @@ + Vouch Proxy: {{ .Msg }} + +{{ if .Testing }} +

+

-- test mode --

+The config file includes testing: true +

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

{{ .Msg }}.

+

{{ .Msg }}

+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 see the project page at https://github.com/vouch/vouch-proxy +