diff --git a/.travis.yml b/.travis.yml index 82bc5cbd3bb..bb278c30673 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,16 +13,15 @@ go: - 1.6 install: - - go get github.com/axw/gocov/gocov github.com/mattn/goveralls golang.org/x/tools/cmd/cover github.com/pierrre/gotestcover - # Workaround for travis - - go get -t -v ./... - - go install github.com/ory-am/hydra + - go get github.com/mattn/goveralls golang.org/x/tools/cmd/cover github.com/pierrre/gotestcover github.com/Masterminds/glide - git clone https://github.com/docker-library/official-images.git ~/official-images + - glide install + - go install github.com/ory-am/hydra script: - - go test -bench=.* -run=nothing $(go list ./... | grep -v /vendor) + - gotestcover -coverprofile="cover.out" $(glide novendor) - go test -race $(go list ./... | grep -v /vendor | grep -v /cmd) - - gotestcover -coverprofile="cover.out" $(go list ./... | grep -v /vendor/) + - go test -v -bench=.* -run=none $(glide novendor) - goveralls -coverprofile="cover.out" - docker build -t hydra-travis-ci . - docker run -d hydra-travis-ci diff --git a/README.md b/README.md index bdd33528ae4..adb010b7e5d 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,52 @@ # ![Ory/Hydra](dist/logo.png) -[![Build Status](https://travis-ci.org/ory-am/hydra.svg?branch=master)](https://travis-ci.org/ory-am/hydra) -[![Coverage Status](https://coveralls.io/repos/ory-am/hydra/badge.svg?branch=master&service=github)](https://coveralls.io/github/ory-am/hydra?branch=master) -[![Code Climate](https://codeclimate.com/github/ory-am/hydra/badges/gpa.svg)](https://codeclimate.com/github/ory-am/hydra) -[![Go Report Card](https://goreportcard.com/badge/github.com/ory-am/hydra)](https://goreportcard.com/report/github.com/ory-am/hydra) - [![Join the chat at https://gitter.im/ory-am/hydra](https://img.shields.io/badge/join-chat-00cc99.svg)](https://gitter.im/ory-am/hydra?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Join mailinglist](https://img.shields.io/badge/join-mailinglist-00cc99.svg)](https://groups.google.com/forum/#!forum/ory-hydra/new) [![Join newsletter](https://img.shields.io/badge/join-newsletter-00cc99.svg)](http://eepurl.com/bKT3N9) [![Follow newsletter](https://img.shields.io/badge/follow-twitter-00cc99.svg)](https://twitter.com/_aeneasr) [![Follow GitHub](https://img.shields.io/badge/follow-github-00cc99.svg)](https://github.com/arekkas) -Hydra is being developed by german-based company [Ory](https://ory.am). Join our [newsletter](http://eepurl.com/bKT3N9) to stay on top of new developments. -We respond on [Google Groups](https://groups.google.com/forum/#!forum/ory-hydra/new) and [Gitter](https://gitter.im/ory-am/hydra). Our timezone is CET. +[![Build Status](https://travis-ci.org/ory-am/hydra.svg?branch=master)](https://travis-ci.org/ory-am/hydra) +[![Coverage Status](https://coveralls.io/repos/ory-am/hydra/badge.svg?branch=master&service=github)](https://coveralls.io/github/ory-am/hydra?branch=master) +[![Code Climate](https://codeclimate.com/github/ory-am/hydra/badges/gpa.svg)](https://codeclimate.com/github/ory-am/hydra) +[![Go Report Card](https://goreportcard.com/badge/github.com/ory-am/hydra)](https://goreportcard.com/report/github.com/ory-am/hydra) -Hydra uses the security first OAuth2 and OpenID Connect SDK [Fosite](https://github.com/ory-am/fosite) and [Ladon](https://github.com/ory-am/ladon) for policy-based access control. +Hydra is being developed by german-based company [Ory](https://ory.am). Join our [newsletter](http://eepurl.com/bKT3N9) to stay on top of new developments. We respond to *basic support requests in our free time* on [Google Groups](https://groups.google.com/forum/#!forum/ory-hydra/new) and [Gitter](https://gitter.im/ory-am/hydra). + +If you are looking for 24/7 enterprise support or SLAs, [contact us now](mailto:hello@ory.am). -:fire: Don't want to worry about security updates, backups, migration and scaling? Do you need enterprise support? [Contact us now](mailto:sales@ory.am) and become an early adopter. :fire: +Hydra uses the security first OAuth2 and OpenID Connect SDK [Fosite](https://github.com/ory-am/fosite) and [Ladon](https://github.com/ory-am/ladon) for policy-based access control. **Table of Contents** - [What is Hydra?](#what-is-hydra) - - [Feature Overview](#feature-overview) - - [Can I use Hydra in my new or existing app?](#can-i-use-hydra-in-my-new-or-existing-app) -- [Security](#security) +- [Feature Overview](#feature-overview) - [Quickstart](#quickstart) - [Installation](#installation) - - [Server](#server) - - [Downloading an image from the Hub](#downloading-an-image-from-the-hub) - - [Build a docker image from the source](#build-a-docker-image-from-the-source) - - [Client](#client) - - [Using gopack](#using-gopack) - - [Building from the source](#building-from-the-source) - - [From the Docker container (not recommended):](#from-the-docker-container-not-recommended) - - [Run the example](#run-the-example) + - [Download binaries](#download-binaries) + - [Using Docker](#using-docker) + - [Building from source](#building-from-source) + - [5 minutes tutorial: Run your very own OAuth2 environment](#5-minutes-tutorial-run-your-very-own-oauth2-environment) +- [Security](#security) - [Documentation](#documentation) - [Guide](#guide) - [REST API Documentation](#rest-api-documentation) - [CLI Documentation](#cli-documentation) - [Develop](#develop) - [FAQ](#faq) - - [I'm having trouble with the redirect URI.](#im-having-trouble-with-the-redirect-uri) + - [What is OAuth2 and what is OpenID Connect?](#what-is-oauth2-and-what-is-openid-connect) + - [Should I use OAuth2 tokens for authentication?](#should-i-use-oauth2-tokens-for-authentication) + - [Can I use Hydra in my new or existing app?](#can-i-use-hydra-in-my-new-or-existing-app) + - [I'm having trouble with the redirect URI](#im-having-trouble-with-the-redirect-uri) - [How can I validate tokens?](#how-can-i-validate-tokens) - [How can I import TLS certificates?](#how-can-i-import-tls-certificates) - [I want to disable HTTPS for testing](#i-want-to-disable-https-for-testing) - [Can I set the log level to warn, error, debug, ...?](#can-i-set-the-log-level-to-warn-error-debug-) - [I need to use a custom CA for RethinkDB](#i-need-to-use-a-custom-ca-for-rethinkdb) + - [What will happen if an error occurs during an OAuth2 flow?](#what-will-happen-if-an-error-occurs-during-an-oauth2-flow) + - [Eventually consistent](#eventually-consistent) - [Is there a client library / SDK?](#is-there-a-client-library--sdk) - [Hall of Fame](#hall-of-fame) @@ -65,30 +64,11 @@ Access Control, OAuth2, and OpenID Connect layer** that integrates with every id Hydra is available through [Docker](https://hub.docker.com/r/oryam/hydra/) and relies on RethinkDB for persistence. Database drivers are extensible in case you want to use RabbitMQ, MySQL, MongoDB, or some other database instead. -Hydra is built for high throughput environments. Check out the below siege benchmark on a Macbook Pro Late 2013, connected to RethinkDB validating access tokens. - -*Note*: There is no cache involved. You could use a different access token for every request and still get the same benchmark results. +Hydra is built for high throughput environments. Using 10.000 simultaneous connections on a Macbook Pro Late 2013, +the OAuth2 token validation endpoint served on average **37500 requests / sec**. Other endpoints like the JSON Web Key endpoint +serve up to 4700 requests / sec. Read [this issue](https://github.com/ory-am/hydra/issues/161) for information on reproducing these benchmarks yourself. -``` -# 500 concurrent connections, 5 minutes stress test -$ siege -t5m -c=500 -b --content-type="application/json" --header="Authorization: bearer 14Q28Y5PILTSKhByweAmQ1JddECMtp4j65yvlKs5Rhk.mqkREUzhqKexxGct5tA4UhJSnL8RYMpwMeFlEF-10OM" 'https://localhost:4444/warden/authorized POST {"assertion": "14Q28Y5PILTSKhByweAmQ1JddECMtp4j65yvlKs5Rhk.mqkREUzhqKexxGct5tA4UhJSnL8RYMpwMeFlEF-10OM"}' | grep '^[^HTTP]+' - -Lifting the server siege... -Transactions: 155455 hits -Availability: 100.00 % -Elapsed time: 299.67 secs -Data transferred: 32.47 MB -Response time: 0.00 secs -Transaction rate: 518.75 trans/sec -Throughput: 0.11 MB/sec -Concurrency: 0.96 -Successful transactions: 155455 -Failed transactions: 0 -Longest transaction: 6.80 -Shortest transaction: 0.00 -``` - -### Feature Overview +## Feature Overview 1. **Availability:** Hydra uses pub/sub to have the latest data available in memory. The in-memory architecture allows for heavy duty workloads. 2. **Scalability:** Hydra scales effortlessly on every platform you can imagine, including Heroku, Cloud Foundry, Docker, @@ -104,128 +84,78 @@ Hydra is packaged using [Docker](https://hub.docker.com/r/oryam/hydra/). 6. **Open Source:** Hydra is licensed under Apache Version 2.0 7. **Professional:** Hydra implements peer reviewed open standards published by [The Internet Engineering Task Force (IETF®)](https://www.ietf.org/) and the [OpenID Foundation](https://openid.net/) and under supervision of the [LMU Teaching and Research Unit Programming and Modelling Languages](http://www.en.pms.ifi.lmu.de). No funny business. -8. **Real Time:** Operation is a lot easier with real time monitoring. Because Hydra leverages RethinkDB, you get real time monitoring for free: -![monitoring.gif](dist/monitoring.gif) +8. **Real Time:** Operation is a lot easier with real time. There are no caches, + no invalidation strategies and no magic - just simple, cloud native pub-sub. Hydra leverages RethinkDB, so check out their real time database monitoring too! -### Can I use Hydra in my new or existing app? - -OAuth2 and OpenID Connect are tricky to understand. It is important to understand that OAuth2 is -a delegation protocol. It makes sense to use Hydra in new and existing projects. A use case covering an existing project -explains how one would use Hydra in a new one as well. So let's look at a use case! +
-Let's assume we are running a ToDo List App (todo24.com). ToDo24 has a login endpoint (todo24.com/login). -The login endpoint is written in node and uses MongoDB to store user information (email + password + settings). Of course, -todo24 has other services as well: list management (todo24.com/lists/manage: close, create, move), item management (todo24.com/lists/items/manage: mark solved, add), and so on. -You are using cookies to see which user is performing the request. +## Quickstart -Now you decide to use OAuth2 on top of your current infrastructure. There are many reasons to do this: -* You want to open up your APIs to third-party developers. Their apps will be using OAuth2 Access Tokens to access a user's to do list. -* You want a mobile client. Because you can not store secrets on devices (they can be reverse engineered and stolen), you use OAuth2 Access Tokens instead. -* You have Cross Origin Requests. Making cookies work with Cross Origin Requests weakens or even disables important anti-CSRF measures. -* You want to write an in-browser client. This is the same case as in a mobile client (you can't store secrets in a browser). +This section is a quickstart guide to working with Hydra. In-depth docs are available as well: -These are only a couple of reasons to use OAuth2. You might decide to use OAuth2 as your single source of authorization, thus maintaining -only one authorization protocol and being able to open up to third party devs in no time. With OpenID Connect, you are able to delegate authentication as well as authorization! +* The documentation is available on [GitBook](https://ory-am.gitbooks.io/hydra/content/). +* The REST API documentation is available at [Apiary](http://docs.hdyra.apiary.io). -Your decision is final. You want to use OAuth2 and you want Hydra to do the job. You install Hydra in your cluster using docker. -Next, you set up some exemplary OAuth2 clients. Clients can act on their own, but most of the time they need to access a user's todo lists. -To do so, the client initiates an OAuth2 request. This is where [Hydra's authentication flow](https://ory-am.gitbooks.io/hydra/content/oauth2.html#authentication-flow) comes in to play. -Before Hydra can issue an access token, we need to know WHICH user is giving consent. To do so, Hydra redirects the user agent (e.g. browser, mobile device) -to the login endpoint alongside with a challenge that contains an expiry time and other information. The login endpoint (todo24.com/login) authenticates the -user as usual, e.g. by username & password, session cookie or other means. Upon successful authentication, the login endpoint asks for the user's consent: -*"Do you want to grant MyCoolAnalyticsApp read & write access to all your todo lists? [Yes] [No]"*. Once the user clicks *Yes* and gives consent, -the login endpoint redirects back to hydra and appends something called a *consent token*. The consent token is a cryptographically signed -string that contains information about the user, specifically the user's unique id. Hydra validates the signature's trustworthiness -and issues an OAuth2 access token and optionally a refresh or OpenID token. +### Installation -Every time a request containing an access token hits a resource server (todo24.com/lists/manage), you make a request to Hydra asking who the token's -subject (the user who authorized the client to create a token on its behalf) is and whether the token is valid or not. You may optionally -ask if the token has permission to perform a certain action. +There are various ways of installing hydra on your system. -## Security +#### Download binaries -*Why should I use Hydra? It's not that hard to implement two OAuth2 endpoints and there are numerous SDKs out there!* +The client and server **binaries are downloadable at [releases](https://github.com/ory-am/hydra/releases)**. +There is currently no installer available. You have to add the hydra binary to the PATH environment variable yourself or put +the binary in a location that is already in your path (`/usr/bin`, ...). +If you do not understand what that all of this means, ask in our [chat channel](https://gitter.im/ory-am/hydra). We are happy to help. -OAuth2 and OAuth2 related specifications are over 200 written pages. Implementing OAuth2 is easy, getting it right is hard. -Even if you use a secure SDK (there are numerous SDKs not secure by design in the wild), messing up the implementation -is a real threat - no matter how good you or your team is. To err is human. +#### Using Docker -Let's take a look at security in Hydra: -* Hydra uses [Fosite](https://github.com/ory-am/fosite#a-word-on-security), a secure-by-design OAuth2 SDK. Fosite implements -best practices proposed by the IETF: - * [No Cleartext Storage of Credentials](https://tools.ietf.org/html/rfc6819#section-5.1.4.1.3) - * [Encryption of Credentials](https://tools.ietf.org/html/rfc6819#section-5.1.4.1.4) - * [Use Short Expiration Time](https://tools.ietf.org/html/rfc6819#section-5.1.5.3) - * [Limit Number of Usages or One-Time Usage](https://tools.ietf.org/html/rfc6819#section-5.1.5.4) - * [Bind Token to Client id](https://tools.ietf.org/html/rfc6819#section-5.1.5.8) - * [Automatic Revocation of Derived Tokens If Abuse Is Detected](https://tools.ietf.org/html/rfc6819#section-5.2.1.1) - * [Binding of Refresh Token to "client_id"](https://tools.ietf.org/html/rfc6819#section-5.2.2.2) - * [Refresh Token Rotation](https://tools.ietf.org/html/rfc6819#section-5.2.2.3) - * [Revocation of Refresh Tokens](https://tools.ietf.org/html/rfc6819#section-5.2.2.4) - * [Validate Pre-Registered "redirect_uri"](https://tools.ietf.org/html/rfc6819#section-5.2.3.5) - * [Binding of Authorization "code" to "client_id"](https://tools.ietf.org/html/rfc6819#section-5.2.4.4) - * [Binding of Authorization "code" to "redirect_uri"](https://tools.ietf.org/html/rfc6819#section-5.2.4.6) - * [Opaque access tokens](https://tools.ietf.org/html/rfc6749#section-1.4) - * [Opaque refresh tokens](https://tools.ietf.org/html/rfc6749#section-1.5) - * [Ensure Confidentiality of Requests](https://tools.ietf.org/html/rfc6819#section-5.1.1) - * [Use of Asymmetric Cryptography](https://tools.ietf.org/html/rfc6819#section-5.1.4.1.5) - * **Enforcing random states:** Without a random-looking state or OpenID Connect nonce the request will fail. - * **Advanced Token Validation:** Tokens are laid out as `.` where `` is created using HMAC-SHA256 - and a global secret. This is what a token can look like: `/tgBeUhWlAT8tM8Bhmnx+Amf8rOYOUhrDi3pGzmjP7c=.BiV/Yhma+5moTP46anxMT6cWW8gz5R5vpC9RbpwSDdM=` - * **Enforcing scopes:** By default, you always need to include the `core` scope or Hydra will not execute the request. -* Hydra uses [Ladon](https://github.com/ory-am/ladon) for policy management and access control. Ladon's API is minimalistic -and well tested. -* Hydra encrypts symmetric and asymmetric keys at rest using AES-GCM 256bit. -* Hydra does not store tokens, only their signatures. An attacker gaining database access is neither able to steal tokens nor -to issue new ones. -* Hydra has automated unit and integration tests. -* Hydra does not use hacks. We would rather rewrite the whole thing instead of introducing a hack. -* APIs are uniform, well documented and secured using the warden's access control infrastructure. -* Hydra is open source and can be reviewed by anyone. -* Hydra is designed by a [security enthusiast](https://github.com/arekkas), who has written and participated in numerous auth* projects. - -Additionally to the claims above, Hydra has received a lot of positive feedback. Let's see what the community is saying: - -> Nice! Lowering barriers to the use of technologies like these is important. +**Starting the host** is easiest with docker. The host process handles HTTP requests and is backed by a database. +Read how to install docker on [Linux](https://docs.docker.com/linux/), [OSX](https://docs.docker.com/mac/) or +[Windows](https://docs.docker.com/windows/). Hydra is available on [Docker Hub](https://hub.docker.com/r/oryam/hydra/). -[Pyxl101](https://news.ycombinator.com/item?id=11798641) +You can use Hydra without a database, but be aware that restarting, scaling +or stopping the container will **lose all data**: -> OAuth is a framework not a protocol. The security it provides can vary greatly between implementations. -Fosite (which is what this is based on) is a very good implementation from a security perspective: https://github.com/ory-am/fosite#a-word-on-security +``` +$ docker run -d -p 4444:4444 oryam/hydra --name my-hydra +ec91228cb105db315553499c81918258f52cee9636ea2a4821bdb8226872f54b +``` -[abritishguy](https://news.ycombinator.com/item?id=11800515) +**Using the client command line interface:** You can ssh into the hydra container +and execute the hydra command from there: -> [...] Thanks for releasing this by the way, looks really well engineered. [...] +``` +$ docker exec -i -t /bin/bash +# e.g. docker exec -i -t ec91228 /bin/bash -[olalonde](https://news.ycombinator.com/item?id=11798831) +root@ec91228cb105:/go/src/github.com/ory-am/hydra# hydra +Hydra is a twelve factor OAuth2 and OpenID Connect provider -## Quickstart +[...] +``` -This section is a quickstart guide to working with Hydra. In-depth docs are available as well: +#### Building from source -* The documentation is available on [GitBook](https://ory-am.gitbooks.io/hydra/content/). -* The REST API documentation is available at [Apiary](http://docs.hdyra.apiary.io). +If you wish to compile hydra yourself, you need to install and set up [Go 1.5+](https://golang.org/) and add `$GOPATH/bin` +to your `$PATH`. To do so, run the following commands in a shell (bash, sh, cmd.exe, ...): -### Installation +``` +go get github.com/ory-am/hydra +go get github.com/Masterminds/glide +cd $GOPATH/src/github.com/ory-am/hydra +glide install +go install github.com/ory-am/hydra +hydra +``` -#### Server +### 5 minutes tutorial: Run your very own OAuth2 environment -**Starting the host** is easiest with docker. The host process handles HTTP requests and is backed by a database. -Read how to install docker on [Linux](https://docs.docker.com/linux/), [OSX](https://docs.docker.com/mac/) or -[Windows](https://docs.docker.com/windows/). Hydra is available on [Docker Hub](https://hub.docker.com/r/oryam/hydra/). +In this example, you will set up Hydra, a RethinkDB instance and an exemplary identity provider written in React using docker compose. +It will take you about 5 minutes to get complete this tutorial. -##### Downloading an image from the Hub +OAuth2 Flow -Hydra will keep all changes in memory. But be aware: Restarting, scaling -or stopping the container will make you **lose all data**. - -To download and start the image: -``` -$ docker run -d -p 4444:4444 oryam/hydra --name my-hydra -ec91228cb105db315553499c81918258f52cee9636ea2a4821bdb8226872f54b -``` - -##### Build a docker image from the source +Running the example Install the [CLI and Docker Toolbox](#installation). Make sure you install Docker Compose. On OSX and Windows, open the Docker Quickstart Terminal. On Linux, open any terminal. @@ -236,16 +166,11 @@ We will use a dummy password as the system secret: `SYSTEM_SECRET=passwordtutori ``` $ go get github.com/ory-am/hydra $ cd $GOPATH/src/github.com/ory-am/hydra -$ DOCKER_IP=$(docker-machine ip default) docker-compose build -WARNING: The SYSTEM_SECRET variable is not set. Defaulting to a blank string. -rethinkdb uses an image, skipping +$ docker-compose build Building hydra [...] $ SYSTEM_SECRET=passwordtutorial DOCKER_IP=$(docker-machine ip default) docker-compose up -Starting hydra_rethinkdb_1 -Recreating hydra_hydra_1 -Recreating hydra_consent_1 -Attaching to hydra_rethinkdb_1, hydra_hydra_1, hydra_consent_1 +Starting hydra_hydra_1 [...] ``` @@ -253,66 +178,18 @@ Attaching to hydra_rethinkdb_1, hydra_hydra_1, hydra_consent_1 ``` $ go get github.com/ory-am/hydra $ cd $GOPATH/src/github.com/ory-am/hydra -$ DOCKER_IP=localhost docker-compose build -WARNING: The SYSTEM_SECRET variable is not set. Defaulting to a blank string. -rethinkdb uses an image, skipping +$ docker-compose build Building hydra [...] $ SYSTEM_SECRET=passwordtutorial DOCKER_IP=localhost docker-compose up Starting hydra_rethinkdb_1 -Recreating hydra_hydra_1 -Recreating hydra_consent_1 -Attaching to hydra_rethinkdb_1, hydra_hydra_1, hydra_consent_1 [...] mhydra | mtime="2016-05-17T18:09:28Z" level=warning msg="Generated system secret: MnjFP5eLIr60h?hLI1h-!<4(TlWjAHX7" [...] -mhydra | mtime="2016-05-17T18:09:29Z" level=warning msg="Temporary root client created." mhydra | mtime="2016-05-17T18:09:29Z" level=warning msg="client_id: d9227bd5-5d47-4557-957d-2fd3bee11035" mhydra | mtime="2016-05-17T18:09:29Z" level=warning msg="client_secret: ,IvxGt02uNjv1ur9" [...] ``` -#### Client - -##### Using gopack - -**The CLI client is available at [gobuild.io](https://gobuild.io/ory-am/hydra)**. - -There is currently no installer which adds the CLI to your path automatically. You have to set up the path yourself. -If you do not understand what that means, ask on our [Gitter channel](https://gitter.im/ory-am/hydra). - -##### Building from the source - -If you wish to compile the CLI yourself, you need to install and set up [Go](https://golang.org/) and add `$GOPATH/bin` -to your `$PATH`. Here is a [comprehensive Go installation guide](https://github.com/ory-am/workshop-dbg#googles-go-language) with screenshots. - -To install the CLI from source, execute: - -``` -go get github.com/ory-am/hydra -go get github.com/Masterminds/glide -cd $GOPATH/src/github.com/ory-am/hydra -glide install -go install github.com/ory-am/hydra -hydra -``` -##### From the Docker container (not recommended): - -``` -$ docker exec -i -t /bin/bash -# e.g. docker exec -i -t ec12 /bin/bash - -root@ec91228cb105:/go/src/github.com/ory-am/hydra# hydra -Hydra is a twelve factor OAuth2 and OpenID Connect provider - -Usage: - hydra [command] - -[...] -``` - -### Run the example - -![Run the example](dist/run-the-example.gif) You now have a running hydra docker container! Additionally, a RethinkDB image was deployed as well as a consent app. @@ -351,7 +228,6 @@ Great! You are now connected to Hydra and can start by creating a new client: ``` $ hydra clients create --skip-tls-verify -Warning: Skipping TLS Certificate Verification. Client ID: c003830f-a090-4721-9463-92424270ce91 Client Secret: Z2pJ0>Tp7.ggn>EE&rhnOzdt1 ``` @@ -363,7 +239,6 @@ Why not issue an access token for your client? ``` $ hydra token client --skip-tls-verify -Warning: Skipping TLS Certificate Verification. JLbnRS9GQmzUBT4x7ESNw0kj2wc0ffbMwOv3QQZW4eI.qkP-IQXn6guoFew8TvaMFUD-SnAyT8GmWuqGi3wuWXg ``` @@ -371,17 +246,71 @@ Let's try this with the authorize code grant! ``` $ hydra token user --skip-tls-verify -Warning: Skipping TLS Certificate Verification. -If your browser does not open automatically, navigate to: https://192.168.99.100:4444/oauth2/auth?client_id=d9227bd5-5d47-4557-957d-2fd3bee11035&response_type=code&scope=core+hydra&state=sbnwdelqzxyedwtqinxnolbr&nonce=sffievieeesltbjkwxyhycyq +If your browser does not open automatically, navigate to: https://192.168.99.100:4444/oauth2/... Setting up callback listener on http://localhost:4445/callback Press ctrl + c on Linux / Windows or cmd + c on OSX to end the process. ``` -![OAuth2 Flow](dist/oauth2-flow.gif) - Great! You installed hydra, connected the CLI, created a client and completed two authentication flows! Your next stop should be the [Guide](#guide). +## Security + +*Why should I use Hydra? It's not that hard to implement two OAuth2 endpoints and there are numerous SDKs out there!* + +OAuth2 and OAuth2 related specifications are over 200 written pages. Implementing OAuth2 is easy, getting it right is hard. +Even if you use a secure SDK (there are numerous SDKs not secure by design in the wild), messing up the implementation +is a real threat - no matter how good you or your team is. To err is human. + +Let's take a look at security in Hydra: +* Hydra uses [Fosite](https://github.com/ory-am/fosite#a-word-on-security), a secure-by-design OAuth2 SDK. Fosite implements +best practices proposed by the IETF: + * [No Cleartext Storage of Credentials](https://tools.ietf.org/html/rfc6819#section-5.1.4.1.3) + * [Encryption of Credentials](https://tools.ietf.org/html/rfc6819#section-5.1.4.1.4) + * [Use Short Expiration Time](https://tools.ietf.org/html/rfc6819#section-5.1.5.3) + * [Limit Number of Usages or One-Time Usage](https://tools.ietf.org/html/rfc6819#section-5.1.5.4) + * [Bind Token to Client id](https://tools.ietf.org/html/rfc6819#section-5.1.5.8) + * [Automatic Revocation of Derived Tokens If Abuse Is Detected](https://tools.ietf.org/html/rfc6819#section-5.2.1.1) + * [Binding of Refresh Token to "client_id"](https://tools.ietf.org/html/rfc6819#section-5.2.2.2) + * [Refresh Token Rotation](https://tools.ietf.org/html/rfc6819#section-5.2.2.3) + * [Revocation of Refresh Tokens](https://tools.ietf.org/html/rfc6819#section-5.2.2.4) + * [Validate Pre-Registered "redirect_uri"](https://tools.ietf.org/html/rfc6819#section-5.2.3.5) + * [Binding of Authorization "code" to "client_id"](https://tools.ietf.org/html/rfc6819#section-5.2.4.4) + * [Binding of Authorization "code" to "redirect_uri"](https://tools.ietf.org/html/rfc6819#section-5.2.4.6) + * [Opaque access tokens](https://tools.ietf.org/html/rfc6749#section-1.4) + * [Opaque refresh tokens](https://tools.ietf.org/html/rfc6749#section-1.5) + * [Ensure Confidentiality of Requests](https://tools.ietf.org/html/rfc6819#section-5.1.1) + * [Use of Asymmetric Cryptography](https://tools.ietf.org/html/rfc6819#section-5.1.4.1.5) + * **Enforcing random states:** Without a random-looking state or OpenID Connect nonce the request will fail. + * **Advanced Token Validation:** Tokens are laid out as `.` where `` is created using HMAC-SHA256 + and a global secret. This is what a token can look like: `/tgBeUhWlAT8tM8Bhmnx+Amf8rOYOUhrDi3pGzmjP7c=.BiV/Yhma+5moTP46anxMT6cWW8gz5R5vpC9RbpwSDdM=` + * **Enforcing scopes:** By default, you always need to include the `core` scope or Hydra will not execute the request. +* Hydra uses [Ladon](https://github.com/ory-am/ladon) for policy management and access control. Ladon's API is minimalistic +and well tested. +* Hydra encrypts symmetric and asymmetric keys at rest using AES-GCM 256bit. +* Hydra does not store tokens, only their signatures. An attacker gaining database access is neither able to steal tokens nor +to issue new ones. +* Hydra has automated unit and integration tests. +* Hydra does not use hacks. We would rather rewrite the whole thing instead of introducing a hack. +* APIs are uniform, well documented and secured using the warden's access control infrastructure. +* Hydra is open source and can be reviewed by anyone. +* Hydra is designed by a [security enthusiast](https://github.com/arekkas), who has written and participated in numerous auth* projects. + +Additionally to the claims above, Hydra has received a lot of positive feedback. Let's see what the community is saying: + +> Nice! Lowering barriers to the use of technologies like these is important. + +[Pyxl101](https://news.ycombinator.com/item?id=11798641) + +> OAuth is a framework not a protocol. The security it provides can vary greatly between implementations. +Fosite (which is what this is based on) is a very good implementation from a security perspective: https://github.com/ory-am/fosite#a-word-on-security + +[abritishguy](https://news.ycombinator.com/item?id=11800515) + +> [...] Thanks for releasing this by the way, looks really well engineered. [...] + +[olalonde](https://news.ycombinator.com/item?id=11798831) + ## Documentation ### Guide @@ -423,7 +352,59 @@ DATABASE_URL=rethinkdb://$(docker-machine ip default):28015/hydra go run main.go ## FAQ -### I'm having trouble with the redirect URI. +### What is OAuth2 and what is OpenID Connect? + +* For OAuth2 explanation, I recommend reading the [Dropbox OAuth2 Guide](https://www.dropbox.com/developers/reference/oauth-guide) +* For OpenID, I recommend reading [OpenID Connect explained](http://connect2id.com/learn/openid-connect) + +### Should I use OAuth2 tokens for authentication? + +OAuth2 tokens are like money. It allows you to buy stuff, but the cashier does not really care if the money is +yours or if you stole it, as long as it's valid money. Depending on what you understand as authentication, this is a yes and no answer: + +* **Yes:** You can use access tokens to find out which user ("subject") is performing an action in a resource provider (blog article service, shopping basket, ...). +Coming back to the money example: *You*, the subject, receives a cappuccino from the vendor (resource provider) in exchange for money (access token). +* **No:** Never use access tokens for logging people in, for example `http://myapp.com/login?access_token=...`. +Coming back to the money example: The police officer ("authentication server") will not accept money ("access token") as a proof of identity ("it's really you"). Unless he is corrupt ("vulnerable"), of course. + +In the second example ("authentication server"), you must use OpenID Connect ID Tokens. + +### Can I use Hydra in my new or existing app? + +OAuth2 and OpenID Connect are tricky to understand. It is important to understand that OAuth2 is +a delegation protocol. It makes sense to use Hydra in new and existing projects. A use case covering an existing project +explains how one would use Hydra in a new one as well. So let's look at a use case! + +Let's assume we are running a ToDo List App (todo24.com). ToDo24 has a login endpoint (todo24.com/login). +The login endpoint is written in node and uses MongoDB to store user information (email + password + settings). Of course, +todo24 has other services as well: list management (todo24.com/lists/manage: close, create, move), item management (todo24.com/lists/items/manage: mark solved, add), and so on. +You are using cookies to see which user is performing the request. + +Now you decide to use OAuth2 on top of your current infrastructure. There are many reasons to do this: +* You want to open up your APIs to third-party developers. Their apps will be using OAuth2 Access Tokens to access a user's to do list. +* You want a mobile client. Because you can not store secrets on devices (they can be reverse engineered and stolen), you use OAuth2 Access Tokens instead. +* You have Cross Origin Requests. Making cookies work with Cross Origin Requests weakens or even disables important anti-CSRF measures. +* You want to write an in-browser client. This is the same case as in a mobile client (you can't store secrets in a browser). + +These are only a couple of reasons to use OAuth2. You might decide to use OAuth2 as your single source of authorization, thus maintaining +only one authorization protocol and being able to open up to third party devs in no time. With OpenID Connect, you are able to delegate authentication as well as authorization! + +Your decision is final. You want to use OAuth2 and you want Hydra to do the job. You install Hydra in your cluster using docker. +Next, you set up some exemplary OAuth2 clients. Clients can act on their own, but most of the time they need to access a user's todo lists. +To do so, the client initiates an OAuth2 request. This is where [Hydra's authentication flow](https://ory-am.gitbooks.io/hydra/content/oauth2.html#authentication-flow) comes in to play. +Before Hydra can issue an access token, we need to know WHICH user is giving consent. To do so, Hydra redirects the user agent (e.g. browser, mobile device) +to the login endpoint alongside with a challenge that contains an expiry time and other information. The login endpoint (todo24.com/login) authenticates the +user as usual, e.g. by username & password, session cookie or other means. Upon successful authentication, the login endpoint asks for the user's consent: +*"Do you want to grant MyCoolAnalyticsApp read & write access to all your todo lists? [Yes] [No]"*. Once the user clicks *Yes* and gives consent, +the login endpoint redirects back to hydra and appends something called a *consent token*. The consent token is a cryptographically signed +string that contains information about the user, specifically the user's unique id. Hydra validates the signature's trustworthiness +and issues an OAuth2 access token and optionally a refresh or OpenID token. + +Every time a request containing an access token hits a resource server (todo24.com/lists/manage), you make a request to Hydra asking who the token's +subject (the user who authorized the client to create a token on its behalf) is and whether the token is valid or not. You may optionally +ask if the token has permission to perform a certain action. + +### I'm having trouble with the redirect URI Hydra enforces HTTPS for all hosts except localhost. Also make sure that the path is an exact match. `http://localhost:123/` is not the same as `http://localhost:123`. @@ -454,7 +435,7 @@ Or by specifying the following flags: ### I want to disable HTTPS for testing -You can do so by running `hydra host --force-dangerous-http`. +You can do so by running `hydra host --dangerous-force-http`. ### Can I set the log level to warn, error, debug, ...? @@ -479,6 +460,17 @@ or via command line flag: --rethink-tls-cert-path string Path to the certificate file to connect to rethinkdb over TLS (https). You can set RETHINK_TLS_CERT_PATH or RETHINK_TLS_CERT instead. ``` +### What will happen if an error occurs during an OAuth2 flow? + +The user agent will either, according to spec, be redirected to the OAuth2 client who initiated the request, if possible. If not, the user agent will be redirected to the identity provider +endpoint and an `error` and `error_description` query parameter will be appended to it's URL. + +### Eventually consistent + +Using hydra with RethinkDB implies eventual consistency on all endpoints, except `/oauth2/auth` and `/oauth2/token`. +Eventual consistent data is usually not immediately available. This is dependent on the network latency between Hydra +and RethinkDB. + ### Is there a client library / SDK? Yes, for Go! It is available at `github.com/ory-am/hydra/sdk`. @@ -585,7 +577,7 @@ Validate requests with the Warden, uses [`ory-am/hydra/warden.HTTPWarden`](warde import "github.com/ory-am/ladon" // Check if action is allowed -hydra.Warden.HTTPActionAllowed(ctx, req, &ladon.Request{ +hydra.Warden.HTTPRequestAllowed(ctx, req, &ladon.Request{ Resource: "urn:media:images", Action: "get", Subject: "bob", diff --git a/client/client.go b/client/client.go index ec1cfcbadf1..15b7286aca3 100644 --- a/client/client.go +++ b/client/client.go @@ -1,6 +1,9 @@ package client -import "github.com/ory-am/fosite" +import ( + "github.com/ory-am/fosite" + "strings" +) type Client struct { ID string `json:"id" gorethink:"id"` @@ -9,7 +12,7 @@ type Client struct { RedirectURIs []string `json:"redirect_uris" gorethink:"redirect_uris"` GrantTypes []string `json:"grant_types" gorethink:"grant_types"` ResponseTypes []string `json:"response_types" gorethink:"response_types"` - GrantedScopes []string `json:"granted_scopes" gorethink:"granted_scopes"` + Scopes string `json:"scopes" gorethink:"scopes"` Owner string `json:"owner" gorethink:"owner"` PolicyURI string `json:"policy_uri" gorethink:"policy_uri"` TermsOfServiceURI string `json:"tos_uri" gorethink:"tos_uri"` @@ -30,10 +33,8 @@ func (c *Client) GetHashedSecret() []byte { return []byte(c.Secret) } -func (c *Client) GetGrantedScopes() fosite.Scopes { - return &fosite.DefaultScopes{ - Scopes: c.GrantedScopes, - } +func (c *Client) GetScopes() fosite.Arguments { + return fosite.Arguments(strings.Split(c.Scopes, " ")) } func (c *Client) GetGrantTypes() fosite.Arguments { diff --git a/client/handler.go b/client/handler.go index 6ada5eff9a8..15c7294f0d3 100644 --- a/client/handler.go +++ b/client/handler.go @@ -45,7 +45,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, _ httprouter.Pa return } - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: ClientsResource, Action: "create", Context: ladon.Context{ @@ -80,7 +80,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, _ httprouter.Pa func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var ctx = herodot.NewContext() - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: ClientsResource, Action: "get", }, Scope); err != nil { @@ -106,13 +106,13 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request, ps httprouter.Para var ctx = herodot.NewContext() var id = ps.ByName("id") - c, err := h.Manager.GetClient(id) + c, err := h.Manager.GetConcreteClient(id) if err != nil { h.H.WriteError(ctx, w, r, err) return } - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: fmt.Sprintf(ClientResource, id), Action: "get", Context: ladon.Context{ @@ -123,7 +123,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request, ps httprouter.Para return } - c.(*Client).Secret = "" + c.Secret = "" h.H.Write(ctx, w, r, c) } @@ -131,7 +131,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request, ps httprouter.P var ctx = herodot.NewContext() var id = ps.ByName("id") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: fmt.Sprintf(ClientResource, id), Action: "delete", }, Scope); err != nil { diff --git a/client/manager.go b/client/manager.go index d38fbcae395..3e14bcd3bad 100644 --- a/client/manager.go +++ b/client/manager.go @@ -18,4 +18,6 @@ type Storage interface { DeleteClient(id string) error GetClients() (map[string]Client, error) + + GetConcreteClient(id string) (*Client, error) } diff --git a/client/manager_http.go b/client/manager_http.go index e1675c75059..d1d67b31f9b 100644 --- a/client/manager_http.go +++ b/client/manager_http.go @@ -14,7 +14,7 @@ type HTTPManager struct { Dry bool } -func (m *HTTPManager) GetClient(id string) (fosite.Client, error) { +func (m *HTTPManager) GetConcreteClient(id string) (*Client, error) { var c Client var r = pkg.NewSuperAgent(pkg.JoinURL(m.Endpoint, id).String()) r.Client = m.Client @@ -26,6 +26,10 @@ func (m *HTTPManager) GetClient(id string) (fosite.Client, error) { return &c, nil } +func (m *HTTPManager) GetClient(id string) (fosite.Client, error) { + return m.GetConcreteClient(id) +} + func (m *HTTPManager) CreateClient(c *Client) error { var r = pkg.NewSuperAgent(m.Endpoint.String()) r.Client = m.Client diff --git a/client/manager_memory.go b/client/manager_memory.go index 015f122f1cb..3ea968856a5 100644 --- a/client/manager_memory.go +++ b/client/manager_memory.go @@ -16,7 +16,7 @@ type MemoryManager struct { sync.RWMutex } -func (m *MemoryManager) GetClient(id string) (fosite.Client, error) { +func (m *MemoryManager) GetConcreteClient(id string) (*Client, error) { m.RLock() defer m.RUnlock() @@ -27,6 +27,10 @@ func (m *MemoryManager) GetClient(id string) (fosite.Client, error) { return &c, nil } +func (m *MemoryManager) GetClient(id string) (fosite.Client, error) { + return m.GetConcreteClient(id) +} + func (m *MemoryManager) Authenticate(id string, secret []byte) (*Client, error) { m.RLock() defer m.RUnlock() diff --git a/client/manager_rethinkdb.go b/client/manager_rethinkdb.go index 9276e34b865..c3e6e29b43b 100644 --- a/client/manager_rethinkdb.go +++ b/client/manager_rethinkdb.go @@ -23,7 +23,7 @@ type RethinkManager struct { Hasher hash.Hasher } -func (m *RethinkManager) GetClient(id string) (fosite.Client, error) { +func (m *RethinkManager) GetConcreteClient(id string) (*Client, error) { m.RLock() defer m.RUnlock() @@ -34,6 +34,10 @@ func (m *RethinkManager) GetClient(id string) (fosite.Client, error) { return &c, nil } +func (m *RethinkManager) GetClient(id string) (fosite.Client, error) { + return m.GetConcreteClient(id) +} + func (m *RethinkManager) Authenticate(id string, secret []byte) (*Client, error) { m.RLock() defer m.RUnlock() diff --git a/client/manager_test.go b/client/manager_test.go index fa94ca3552a..cf70e061841 100644 --- a/client/manager_test.go +++ b/client/manager_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/julienschmidt/httprouter" + "github.com/ory-am/dockertest" "github.com/ory-am/fosite" "github.com/ory-am/fosite/hash" . "github.com/ory-am/hydra/client" @@ -22,7 +23,6 @@ import ( "github.com/pborman/uuid" "github.com/stretchr/testify/assert" "golang.org/x/net/context" - "gopkg.in/ory-am/dockertest.v2" ) var clientManagers = map[string]Storage{} diff --git a/cmd/cli/handler_client.go b/cmd/cli/handler_client.go index 063b7c9cc83..48a532ae89e 100644 --- a/cmd/cli/handler_client.go +++ b/cmd/cli/handler_client.go @@ -9,6 +9,7 @@ import ( "github.com/ory-am/hydra/config" "github.com/ory-am/hydra/pkg" "github.com/spf13/cobra" + "strings" ) type ClientHandler struct { @@ -24,7 +25,6 @@ func newClientHandler(c *config.Config) *ClientHandler { } func (h *ClientHandler) ImportClients(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry h.M.Endpoint = h.Config.Resolve("/clients") h.M.Client = h.Config.OAuth2Client(cmd) if len(args) == 0 { @@ -52,7 +52,7 @@ func (h *ClientHandler) ImportClients(cmd *cobra.Command, args []string) { func (h *ClientHandler) CreateClient(cmd *cobra.Command, args []string) { var err error - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/clients") h.M.Client = h.Config.OAuth2Client(cmd) @@ -70,7 +70,7 @@ func (h *ClientHandler) CreateClient(cmd *cobra.Command, args []string) { ID: id, Secret: string(secret), ResponseTypes: responseTypes, - GrantedScopes: allowedScopes, + Scopes: strings.Join(allowedScopes, " "), GrantTypes: grantTypes, RedirectURIs: callbacks, Name: name, @@ -87,7 +87,6 @@ func (h *ClientHandler) CreateClient(cmd *cobra.Command, args []string) { } func (h *ClientHandler) DeleteClient(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry h.M.Endpoint = h.Config.Resolve("/clients") h.M.Client = h.Config.OAuth2Client(cmd) if len(args) == 0 { diff --git a/cmd/cli/handler_connection.go b/cmd/cli/handler_connection.go index 6113b330819..4370ecacdea 100644 --- a/cmd/cli/handler_connection.go +++ b/cmd/cli/handler_connection.go @@ -23,7 +23,7 @@ func newConnectionHandler(c *config.Config) *ConnectionHandler { } func (h *ConnectionHandler) CreateConnection(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Client = h.Config.OAuth2Client(cmd) h.M.Endpoint = h.Config.Resolve("/connections") if len(args) != 3 { @@ -45,7 +45,7 @@ func (h *ConnectionHandler) CreateConnection(cmd *cobra.Command, args []string) } func (h *ConnectionHandler) DeleteConnection(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Client = h.Config.OAuth2Client(cmd) h.M.Endpoint = h.Config.Resolve("/connections") if len(args) == 0 { diff --git a/cmd/cli/handler_jwk.go b/cmd/cli/handler_jwk.go index 5ef53698c86..805fe3bbc5c 100644 --- a/cmd/cli/handler_jwk.go +++ b/cmd/cli/handler_jwk.go @@ -23,7 +23,7 @@ func newJWKHandler(c *config.Config) *JWKHandler { } func (h *JWKHandler) CreateKeys(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/keys") h.M.Client = h.Config.OAuth2Client(cmd) if len(args) == 0 { @@ -46,7 +46,7 @@ func (h *JWKHandler) CreateKeys(cmd *cobra.Command, args []string) { } func (h *JWKHandler) GetKeys(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/keys") h.M.Client = h.Config.OAuth2Client(cmd) if len(args) == 0 { @@ -68,6 +68,7 @@ func (h *JWKHandler) GetKeys(cmd *cobra.Command, args []string) { } func (h *JWKHandler) DeleteKeys(cmd *cobra.Command, args []string) { + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/keys") h.M.Client = h.Config.OAuth2Client(cmd) if len(args) == 0 { diff --git a/cmd/cli/handler_policy.go b/cmd/cli/handler_policy.go index 04407f31f0c..a8e1cfba717 100644 --- a/cmd/cli/handler_policy.go +++ b/cmd/cli/handler_policy.go @@ -25,7 +25,7 @@ func newPolicyHandler(c *config.Config) *PolicyHandler { } func (h *PolicyHandler) CreatePolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/policies") h.M.Client = h.Config.OAuth2Client(cmd) @@ -80,7 +80,7 @@ func (h *PolicyHandler) CreatePolicy(cmd *cobra.Command, args []string) { } func (h *PolicyHandler) AddResourceToPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/policies") h.M.Client = h.Config.OAuth2Client(cmd) @@ -112,12 +112,11 @@ func (h *PolicyHandler) AddResourceToPolicy(cmd *cobra.Command, args []string) { } func (h *PolicyHandler) RemoveResourceFromPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry fmt.Println("Not yet implemented.") } func (h *PolicyHandler) AddSubjectToPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/policies") h.M.Client = h.Config.OAuth2Client(cmd) @@ -149,12 +148,11 @@ func (h *PolicyHandler) AddSubjectToPolicy(cmd *cobra.Command, args []string) { } func (h *PolicyHandler) RemoveSubjectFromPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry fmt.Println("Not yet implemented.") } func (h *PolicyHandler) AddActionToPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/policies") h.M.Client = h.Config.OAuth2Client(cmd) @@ -186,12 +184,11 @@ func (h *PolicyHandler) AddActionToPolicy(cmd *cobra.Command, args []string) { } func (h *PolicyHandler) RemoveActionFromPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry fmt.Println("Not yet implemented.") } func (h *PolicyHandler) GetPolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/policies") h.M.Client = h.Config.OAuth2Client(cmd) @@ -214,7 +211,7 @@ func (h *PolicyHandler) GetPolicy(cmd *cobra.Command, args []string) { } func (h *PolicyHandler) DeletePolicy(cmd *cobra.Command, args []string) { - h.M.Dry = *h.Config.Dry + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Endpoint = h.Config.Resolve("/policies") h.M.Client = h.Config.OAuth2Client(cmd) diff --git a/cmd/cli/handler_warden.go b/cmd/cli/handler_warden.go index 097c0ae1a03..f16f7dcfc19 100644 --- a/cmd/cli/handler_warden.go +++ b/cmd/cli/handler_warden.go @@ -24,6 +24,7 @@ func newWardenHandler(c *config.Config) *WardenHandler { } func (h *WardenHandler) IsAuthorized(cmd *cobra.Command, args []string) { + h.M.Dry, _ = cmd.Flags().GetBool("dry") h.M.Client = h.Config.OAuth2Client(cmd) h.M.Endpoint = h.Config.Resolve("/connections") @@ -33,11 +34,7 @@ func (h *WardenHandler) IsAuthorized(cmd *cobra.Command, args []string) { } scopes, _ := cmd.Flags().GetStringSlice("scopes") - if len(scopes) == 0 { - scopes = []string{"core"} - } - - res, err := h.M.Authorized(context.Background(), args[0], scopes...) + res, err := h.M.InspectToken(context.Background(), args[0], scopes...) pkg.Must(err, "Could not validate token: %s", err) out, err := json.MarshalIndent(res, "", "\t") diff --git a/cmd/clients.go b/cmd/clients.go index 24e5e6a4df2..adbb3ab12a2 100644 --- a/cmd/clients.go +++ b/cmd/clients.go @@ -26,12 +26,9 @@ var clientsCmd = &cobra.Command{ } func init() { - var dry bool - c.Dry = &dry - RootCmd.AddCommand(clientsCmd) + clientsCmd.PersistentFlags().Bool("dry", false, "do not execute the command but show the corresponding curl command instead") - clientsCmd.PersistentFlags().BoolVar(c.Dry, "dry", false, "do not execute the command but show the corresponding curl command instead") // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command @@ -41,5 +38,4 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: // clientsCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") - } diff --git a/cmd/clients_create.go b/cmd/clients_create.go index 8c4a57f7a7f..9c9902cdd42 100644 --- a/cmd/clients_create.go +++ b/cmd/clients_create.go @@ -22,6 +22,6 @@ func init() { clientsCreateCmd.Flags().StringSliceP("callbacks", "c", []string{}, "REQUIRED list of allowed callback URLs") clientsCreateCmd.Flags().StringSliceP("grant-types", "g", []string{"authorization_code"}, "A list of allowed grant types") clientsCreateCmd.Flags().StringSliceP("response-types", "r", []string{"code"}, "A list of allowed response types") - clientsCreateCmd.Flags().StringSliceP("allowed-scopes", "a", []string{"core"}, "A list of allowed scopes") + clientsCreateCmd.Flags().StringSliceP("allowed-scopes", "a", []string{""}, "A list of allowed scopes") clientsCreateCmd.Flags().StringP("name", "n", "", "The client's name") } diff --git a/cmd/connect.go b/cmd/connect.go index d8b7bd03787..d4a40b7e024 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -15,13 +15,21 @@ var connectCmd = &cobra.Command{ Use: "connect", Short: "Connect with a cluster", Run: func(cmd *cobra.Command, args []string) { + fmt.Println("To keep the current value, press enter.") + if u := input("Cluster URL [" + c.ClusterURL + "]: "); u != "" { c.ClusterURL = u } - if u := input("Client ID: "); u != "" { + if u := input("Client ID [" + c.ClientID + "]: "); u != "" { c.ClientID = u } - if u := input("Client Secret: "); u != "" { + + secret := "*********" + if c.ClientSecret == "" { + secret = "empty" + } + + if u := input("Client Secret [" + secret + "]: "); u != "" { c.ClientSecret = u } @@ -43,9 +51,5 @@ func input(message string) string { } func init() { - var dry bool - c.Dry = &dry - RootCmd.AddCommand(connectCmd) - connectCmd.PersistentFlags().BoolVar(c.Dry, "dry", false, "do not execute the command but show the corresponding curl command instead") } diff --git a/cmd/connections.go b/cmd/connections.go index c4d9e15b3d8..0437c0a348a 100644 --- a/cmd/connections.go +++ b/cmd/connections.go @@ -13,9 +13,6 @@ Google, Twitter, or any other SSO provider.`, } func init() { - var dry bool - c.Dry = &dry - RootCmd.AddCommand(connectionsCmd) - connectionsCmd.PersistentFlags().BoolVar(c.Dry, "dry", false, "do not execute the command but show the corresponding curl command instead") + connectionsCmd.PersistentFlags().Bool("dry", false, "do not execute the command but show the corresponding curl command instead") } diff --git a/cmd/host.go b/cmd/host.go index bd82a8f4d45..26da1bbadef 100644 --- a/cmd/host.go +++ b/cmd/host.go @@ -1,59 +1,110 @@ package cmd import ( - "crypto/tls" - "net/http" - - "crypto/ecdsa" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "time" - - "github.com/Sirupsen/logrus" - "github.com/go-errors/errors" - "github.com/julienschmidt/httprouter" "github.com/ory-am/hydra/cmd/server" - "github.com/ory-am/hydra/jwk" - "github.com/ory-am/hydra/pkg" "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/square/go-jose" -) - -const ( - TLSKeyName = "hydra.tls" ) // hostCmd represents the host command var hostCmd = &cobra.Command{ Use: "host", Short: "Start the HTTP/2 host service", - Long: `Starts all HTTP/2 APIs and connects to a backend. + Long: `Starts all HTTP/2 APIs and connects to a database backend. + +This command exposes a variety of controls via environment variables. You can +set environments using "export KEY=VALUE" (Linux/macOS) or "set KEY=VALUE" (Windows). On Linux, +you can also set environments by prepending key value pairs: "KEY=VALUE KEY2=VALUE2 hydra" -This command supports the following environment variables: +All possible controls are listed below. The host process additionally exposes a few flags, which are listed below +the controls section. + +CORE CONTROLS +============= - DATABASE_URL: A URL to a persistent backend. Hydra supports various backends: - None: If DATABASE_URL is empty, all data will be lost when the command is killed. - RethinkDB: If DATABASE_URL is a DSN starting with rethinkdb://, RethinkDB will be used as storage backend. + Example: DATABASE_URL=rethinkdb://user:password@host:123/database + + Additionally, these controls are available when using RethinkDB: + - RETHINK_TLS_CERT_PATH: The path to the TLS certificate (pem encoded) used to connect to rethinkdb. + Example: RETHINK_TLS_CERT_PATH=~/rethink.pem + + - RETHINK_TLS_CERT: A pem encoded TLS certificate passed as string. Can be used instead of RETHINK_TLS_CERT_PATH. + Example: RETHINK_TLS_CERT_PATH="-----BEGIN CERTIFICATE-----\nMIIDZTCCAk2gAwIBAgIEV5xOtDANBgkqhkiG9w0BAQ0FADA0MTIwMAYDVQQDDClP..." - SYSTEM_SECRET: A secret that is at least 16 characters long. If none is provided, one will be generated. They key is used to encrypt sensitive data using AES-GCM (256 bit) and validate HMAC signatures. + Example: SYSTEM_SECRET=jf89-jgklAS9gk3rkAF90dfsk + +- FORCE_ROOT_CLIENT_CREDENTIALS: On first start up, Hydra generates a root client with random id and secret. Use + this environment variable in the form of "FORCE_ROOT_CLIENT_CREDENTIALS=id:secret" to set + the client id and secret yourself. + Example: FORCE_ROOT_CLIENT_CREDENTIALS=admin:kf0AKfm12fas3F-.f + +- PORT: The port hydra should listen on. + Defaults to PORT=4444 + +- HOST: The port hydra should listen on. + Example: PORT=localhost + +- BCRYPT_COST: Set the bcrypt hashing cost. This is a trade off between + security and performance. Range is 4 =< x =< 31. + Defaults to BCRYPT_COST=10 + + +OAUTH2 CONTROLS +=============== + +- CONSENT_URL: The uri of the consent endpoint. + Example: CONSENT_URL=https://id.myapp.com/consent + +- ISSUER: The issuer is used for identification in all OAuth2 tokens. + Defaults to ISSUER=hydra.localhost + +- AUTH_CODE_LIFESPAN: Lifespan of OAuth2 authorize codes. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + Defaults to AUTH_CODE_LIFESPAN=10m + +- ID_TOKEN_LIFESPAN: Lifespan of OpenID Connect ID Tokens. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + Defaults to AUTH_CODE_LIFESPAN=1h + +- ACCESS_TOKEN_LIFESPAN: Lifespan of OAuth2 access tokens. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + Defaults to AUTH_CODE_LIFESPAN=1h + +- CHALLENGE_TOKEN_LIFESPAN: Lifespan of OAuth2 consent tokens. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + Defaults to AUTH_CODE_LIFESPAN=10m + + +HTTPS CONTROLS +============== + +- HTTPS_ALLOW_TERMINATION_FROM: Whitelist one or multiple CIDR address ranges and allow them to terminate TLS connections. + Be aware that the X-Forwarded-Proto header must be set and must never be modifiable by anyone but + your proxy / gateway / load balancer. Supports ipv4 and ipv6. + Hydra serves http instead of https when this option is set. + Example: HTTPS_ALLOW_TERMINATION_FROM=127.0.0.1/32,192.168.178.0/24,2620:0:2d0:200::7/32 - HTTPS_TLS_CERT_PATH: The path to the TLS certificate (pem encoded). + Example: HTTPS_TLS_CERT_PATH=~/cert.pem + - HTTPS_TLS_KEY_PATH: The path to the TLS private key (pem encoded). + Example: HTTPS_TLS_KEY_PATH=~/key.pem + - HTTPS_TLS_CERT: A pem encoded TLS certificate passed as string. Can be used instead of HTTPS_TLS_CERT_PATH. + Example: HTTPS_TLS_CERT="-----BEGIN CERTIFICATE-----\nMIIDZTCCAk2gAwIBAgIEV5xOtDANBgkqhkiG9w0BAQ0FADA0MTIwMAYDVQQDDClP..." + - HTTPS_TLS_KEY: A pem encoded TLS key passed as string. Can be used instead of HTTPS_TLS_KEY_PATH. + Example: HTTPS_TLS_KEY="-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDg..." -- RETHINK_TLS_CERT_PATH: The path to the TLS certificate (pem encoded) used to connect to rethinkdb. -- RETHINK_TLS_CERT: A pem encoded TLS certificate passed as string. Can be used instead of RETHINK_TLS_CERT_PATH. -- HYDRA_PROFILING: Set "HYDRA_PROFILING=1" to enable profiling. +DEBUG CONTROLS +============== + +- HYDRA_PROFILING: Set "HYDRA_PROFILING=cpu" to enable cpu profiling and "HYDRA_PROFILING=memory" to enable memory profiling. + It is not possible to do both at the same time. + Example: HYDRA_PROFILING=cpu `, - Run: runHostCmd, + Run: server.RunHost(c), } func init() { @@ -67,179 +118,9 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: - hostCmd.Flags().Bool("force-dangerous-http", false, "Disable HTTP/2 over TLS (HTTPS) and serve HTTP instead. Never use this in production.") + hostCmd.Flags().BoolVar(&c.ForceHTTP, "dangerous-force-http", false, "Disable HTTP/2 over TLS (HTTPS) and serve HTTP instead. Never use this in production.") hostCmd.Flags().Bool("dangerous-auto-logon", false, "Stores the root credentials in ~/.hydra.yml. Do not use in production.") hostCmd.Flags().String("https-tls-key-path", "", "Path to the key file for HTTP/2 over TLS (https). You can set HTTPS_TLS_KEY_PATH or HTTPS_TLS_KEY instead.") hostCmd.Flags().String("https-tls-cert-path", "", "Path to the certificate file for HTTP/2 over TLS (https). You can set HTTPS_TLS_CERT_PATH or HTTPS_TLS_CERT instead.") hostCmd.Flags().String("rethink-tls-cert-path", "", "Path to the certificate file to connect to rethinkdb over TLS (https). You can set RETHINK_TLS_CERT_PATH or RETHINK_TLS_CERT instead.") } - -func runHostCmd(cmd *cobra.Command, args []string) { - router := httprouter.New() - serverHandler := &server.Handler{} - serverHandler.Start(c, router) - - if ok, _ := cmd.Flags().GetBool("dangerous-auto-logon"); ok { - logrus.Warnln("Do not use flag --dangerous-auto-logon in production.") - err := c.Persist() - pkg.Must(err, "Could not write configuration file: %s", err) - } - - http.Handle("/", router) - - var srv = http.Server{ - Addr: c.GetAddress(), - TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{ - getOrCreateTLSCertificate(cmd), - }, - }, - } - - var err error - logrus.Infof("Starting server on %s", c.GetAddress()) - if ok, _ := cmd.Flags().GetBool("force-dangerous-http"); ok { - logrus.Warnln("HTTPS disabled. Never do this in production.") - err = srv.ListenAndServe() - } else { - err = srv.ListenAndServeTLS("", "") - } - pkg.Must(err, "Could not start server: %s %s.", err) -} - -func loadCertificateFromFile(cmd *cobra.Command) *tls.Certificate { - keyPath := viper.GetString("HTTPS_TLS_KEY_PATH") - certPath := viper.GetString("HTTPS_TLS_CERT_PATH") - if kp, _ := cmd.Flags().GetString("https-tls-key-path"); kp != "" { - keyPath = kp - } else if cp, _ := cmd.Flags().GetString("https-tls-cert-path"); cp != "" { - certPath = cp - } else if keyPath == "" || certPath == "" { - return nil - } - - cert, err := tls.LoadX509KeyPair(certPath, keyPath) - if err != nil { - logrus.Warn("Could not load x509 key pair: %s", cert) - return nil - } - return &cert -} - -func loadCertificateFromEnv(cmd *cobra.Command) *tls.Certificate { - keyString := viper.GetString("HTTPS_TLS_KEY") - certString := viper.GetString("HTTPS_TLS_CERT") - if keyString == "" || certString == "" { - return nil - } - - var cert tls.Certificate - var err error - if cert, err = tls.X509KeyPair([]byte(certString), []byte(keyString)); err != nil { - logrus.Warn("Could not parse x509 key pair from env: %s", cert) - return nil - } - - return &cert -} - -func getOrCreateTLSCertificate(cmd *cobra.Command) tls.Certificate { - if cert := loadCertificateFromFile(cmd); cert != nil { - return *cert - } else if cert := loadCertificateFromEnv(cmd); cert != nil { - return *cert - } - - ctx := c.Context() - keys, err := ctx.KeyManager.GetKey(TLSKeyName, "private") - if errors.Is(err, pkg.ErrNotFound) { - logrus.Warn("Key for TLS not found. Creating new one.") - - keys, err = new(jwk.ECDSA256Generator).Generate("") - pkg.Must(err, "Could not generate key: %s", err) - - cert, err := createSelfSignedCertificate(jwk.First(keys.Key("private")).Key) - pkg.Must(err, "Could not create X509 PEM Key Pair: %s", err) - - private := jwk.First(keys.Key("private")) - private.Certificates = []*x509.Certificate{cert} - keys = &jose.JsonWebKeySet{ - Keys: []jose.JsonWebKey{ - *private, - *jwk.First(keys.Key("public")), - }, - } - - err = ctx.KeyManager.AddKeySet(TLSKeyName, keys) - pkg.Must(err, "Could not persist key: %s", err) - } else { - pkg.Must(err, "Could not retrieve key: %s", err) - } - - private := jwk.First(keys.Key("private")) - block, err := jwk.PEMBlockForKey(private.Key) - if err != nil { - pkg.Must(err, "Could not encode key to PEM: %s", err) - } - - if len(private.Certificates) == 0 { - logrus.Fatal("TLS certificate chain can not be empty") - } - - pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: private.Certificates[0].Raw}) - pemKey := pem.EncodeToMemory(block) - cert, err := tls.X509KeyPair(pemCert, pemKey) - pkg.Must(err, "Could not decode certificate: %s", err) - - return cert -} - -func createSelfSignedCertificate(key interface{}) (cert *x509.Certificate, err error) { - serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - if err != nil { - return cert, errors.Errorf("Failed to generate serial number: %s", err) - } - - certificate := &x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{"Hydra"}, - CommonName: "Hydra", - }, - Issuer: pkix.Name{ - Organization: []string{"Hydra"}, - CommonName: "Hydra", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour * 24 * 7), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - - certificate.IsCA = true - certificate.KeyUsage |= x509.KeyUsageCertSign - certificate.DNSNames = append(certificate.DNSNames, "localhost") - der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, publicKey(key), key) - if err != nil { - return cert, errors.Errorf("Failed to create certificate: %s", err) - } - - cert, err = x509.ParseCertificate(der) - if err != nil { - return cert, errors.Errorf("Failed to encode private key: %s", err) - } - return cert, nil -} - -func publicKey(key interface{}) interface{} { - switch k := key.(type) { - case *rsa.PrivateKey: - return &k.PublicKey - case *ecdsa.PrivateKey: - return &k.PublicKey - default: - return nil - } -} diff --git a/cmd/keys.go b/cmd/keys.go index 7214d51e9b4..f880ca626bb 100644 --- a/cmd/keys.go +++ b/cmd/keys.go @@ -25,11 +25,8 @@ var keysCmd = &cobra.Command{ } func init() { - var dry bool - c.Dry = &dry - RootCmd.AddCommand(keysCmd) - keysCmd.PersistentFlags().BoolVar(c.Dry, "dry", false, "do not execute the command but show the corresponding curl command instead") + keysCmd.PersistentFlags().Bool("dry", false, "do not execute the command but show the corresponding curl command instead") // Here you will define your flags and configuration settings. diff --git a/cmd/policies.go b/cmd/policies.go index 4300284175f..90c3745b389 100644 --- a/cmd/policies.go +++ b/cmd/policies.go @@ -11,9 +11,6 @@ var policiesCmd = &cobra.Command{ } func init() { - var dry bool - c.Dry = &dry - RootCmd.AddCommand(policiesCmd) - policiesCmd.PersistentFlags().BoolVar(c.Dry, "dry", false, "do not execute the command but show the corresponding curl command instead") + policiesCmd.PersistentFlags().Bool("dry", false, "do not execute the command but show the corresponding curl command instead") } diff --git a/cmd/root.go b/cmd/root.go index b6600c1bd9b..369526f2e12 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,7 +7,6 @@ import ( "path/filepath" "runtime" "strings" - "sync" "github.com/ory-am/hydra/cmd/cli" "github.com/ory-am/hydra/config" @@ -22,14 +21,13 @@ var c = new(config.Config) // This represents the base command when called without any subcommands var RootCmd = &cobra.Command{ Use: "hydra", - Short: "Hydra is a twelve factor OAuth2 and OpenID Connect provider", + Short: "Hydra is a cloud native high throughput OAuth2 and OpenID Connect provider", // Uncomment the following line if your bare application // has an action associated with it: // Run: func(cmd *cobra.Command, args []string) { }, } var cmdHandler = cli.NewHandler(c) -var mutex = &sync.RWMutex{} // Execute adds all child commands to the root command sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. @@ -57,56 +55,62 @@ func init() { // initConfig reads in config file and ENV variables if set. func initConfig() { - mutex.Lock() if cfgFile != "" { // enable ability to specify config file via flag viper.SetConfigFile(cfgFile) - } + } else { + path := absPathify("$HOME") + if _, err := os.Stat(filepath.Join(path, ".hydra.yml")); err != nil { + _, _ = os.Create(filepath.Join(path, ".hydra.yml")) + } - path := absPathify("$HOME") - if _, err := os.Stat(filepath.Join(path, ".hydra.yml")); err != nil { - _, _ = os.Create(filepath.Join(path, ".hydra.yml")) + viper.SetConfigType("yaml") + viper.SetConfigName(".hydra") // name of config file (without extension) + viper.AddConfigPath("$HOME") // adding home directory as first search path } + viper.AutomaticEnv() // read in environment variables that match - viper.SetConfigType("yaml") - viper.SetConfigName(".hydra") // name of config file (without extension) - viper.AddConfigPath("$HOME") // adding home directory as first search path - viper.AutomaticEnv() // read in environment variables that match + viper.BindEnv("HOST") + viper.BindEnv("CLIENT_ID") + viper.BindEnv("CONSENT_URL") + viper.BindEnv("DATABASE_URL") + viper.BindEnv("SYSTEM_SECRET") + viper.BindEnv("CLIENT_SECRET") + viper.BindEnv("HTTPS_ALLOW_TERMINATION_FROM") - // If a config file is found, read it in. - if err := viper.ReadInConfig(); err != nil { - fmt.Printf(`Config file not found because "%s"`, err) - fmt.Println("") - } + viper.BindEnv("CLUSTER_URL") + viper.SetDefault("CLUSTER_URL", "https://localhost:4444") - if err := viper.Unmarshal(c); err != nil { - fatal("Could not read config because %s.", err) - } + viper.BindEnv("PORT") + viper.SetDefault("PORT", 4444) - if consentURL, ok := viper.Get("CONSENT_URL").(string); ok { - c.ConsentURL = consentURL - } + viper.BindEnv("ISSUER") + viper.SetDefault("ISSUER", "hydra.localhost") - if clientID, ok := viper.Get("CLIENT_ID").(string); ok { - c.ClientID = clientID - } + viper.BindEnv("BCRYPT_COST") + viper.SetDefault("BCRYPT_COST", 10) - if systemSecret, ok := viper.Get("SYSTEM_SECRET").(string); ok { - c.SystemSecret = []byte(systemSecret) - } + viper.BindEnv("ACCESS_TOKEN_LIFESPAN") + viper.SetDefault("ACCESS_TOKEN_LIFESPAN", "1h") - if clientSecret, ok := viper.Get("CLIENT_SECRET").(string); ok { - c.ClientSecret = clientSecret - } + viper.BindEnv("ID_TOKEN_LIFESPAN") + viper.SetDefault("ID_TOKEN_LIFESPAN", "1h") + + viper.BindEnv("AUTH_CODE_LIFESPAN") + viper.SetDefault("AUTH_CODE_LIFESPAN", "10m") + + viper.BindEnv("CHALLENGE_TOKEN_LIFESPAN") + viper.SetDefault("CHALLENGE_TOKEN_LIFESPAN", "10m") - if databaseURL, ok := viper.Get("DATABASE_URL").(string); ok { - c.DatabaseURL = databaseURL + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err != nil { + fmt.Printf(`Config file not found because "%s"`, err) + fmt.Println("") } - if c.ClusterURL == "" { - fmt.Printf("Pointing cluster at %s\n", c.GetClusterURL()) + if err := viper.Unmarshal(c); err != nil { + fatal(fmt.Sprintf("Could not read config because %s.", err)) } - mutex.Unlock() } func absPathify(inPath string) string { diff --git a/cmd/root_test.go b/cmd/root_test.go index 9b9d07e294f..c4ce3053d18 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,13 +1,12 @@ package cmd import ( + "fmt" "os" "path/filepath" "testing" "time" - "fmt" - "github.com/pborman/uuid" "github.com/stretchr/testify/assert" ) @@ -23,7 +22,6 @@ func TestExecute(t *testing.T) { for _, c := range []struct { args []string - timeout time.Duration wait func() bool expectErr bool }{ @@ -31,13 +29,12 @@ func TestExecute(t *testing.T) { args: []string{"host", "--dangerous-auto-logon"}, wait: func() bool { _, err := os.Stat(path) + if err != nil { + t.Logf("Could not stat path %s because %s", path, err) + } return err != nil }, }, - { - args: []string{"token", "user", "--no-open"}, - timeout: time.Second, - }, {args: []string{"clients", "create", "--id", "foobarbaz"}}, {args: []string{"clients", "create", "--id", "foobarbaz", "--dry"}}, {args: []string{"clients", "delete", "foobarbaz"}}, @@ -49,6 +46,7 @@ func TestExecute(t *testing.T) { {args: []string{"connections", "create", "google", "localuser", "googleuser"}}, {args: []string{"connections", "create", "google", "localuser", "googleuser", "--dry"}}, {args: []string{"token", "client"}}, + {args: []string{"policies", "create", "../dist/policies/noone-can-read-private-keys.json"}}, {args: []string{"policies", "create", "-i", "foobar", "-s", "peter", "max", "-r", "blog", "users", "-a", "post", "ban", "--allow"}}, {args: []string{"policies", "create", "-i", "foobar", "-s", "peter", "max", "-r", "blog", "users", "-a", "post", "ban", "--allow", "--dry"}}, {args: []string{"policies", "get", "foobar"}}, @@ -58,20 +56,23 @@ func TestExecute(t *testing.T) { RootCmd.SetArgs(c.args) t.Logf("Running command: %s", c.args) - if c.wait != nil || c.timeout > 0 { + if c.wait != nil { go func() { assert.Nil(t, RootCmd.Execute()) }() } if c.wait != nil { + var count = 0 for c.wait() { - time.Sleep(time.Millisecond * 500) + t.Logf("Config file has not been found yet, retrying attempt #%d...", count) + count++ + if count > 30 { + t.FailNow() + } + time.Sleep(time.Second * 4) } - } else if c.timeout > 0 { - time.Sleep(c.timeout) } else { - assert.Equal(t, c.expectErr, RootCmd.Execute() != nil) } } diff --git a/cmd/server/handler.go b/cmd/server/handler.go index c4a728bad3a..e6c9c7d7c29 100644 --- a/cmd/server/handler.go +++ b/cmd/server/handler.go @@ -1,21 +1,73 @@ package server import ( + "crypto/tls" + "net/http" + "time" + "github.com/Sirupsen/logrus" "github.com/go-errors/errors" "github.com/julienschmidt/httprouter" - "github.com/ory-am/fosite/handler/core" + "github.com/meatballhat/negroni-logrus" "github.com/ory-am/hydra/client" "github.com/ory-am/hydra/config" "github.com/ory-am/hydra/connection" + "github.com/ory-am/hydra/herodot" "github.com/ory-am/hydra/jwk" "github.com/ory-am/hydra/oauth2" "github.com/ory-am/hydra/pkg" "github.com/ory-am/hydra/policy" "github.com/ory-am/hydra/warden" "github.com/ory-am/ladon" + "github.com/spf13/cobra" + "github.com/urfave/negroni" + "golang.org/x/net/context" ) +func RunHost(c *config.Config) func(cmd *cobra.Command, args []string) { + return func(cmd *cobra.Command, args []string) { + router := httprouter.New() + serverHandler := &Handler{Config: c} + serverHandler.registerRoutes(router) + + if ok, _ := cmd.Flags().GetBool("dangerous-auto-logon"); ok { + logrus.Warnln("Do not use flag --dangerous-auto-logon in production.") + err := c.Persist() + pkg.Must(err, "Could not write configuration file: %s", err) + } + + n := negroni.New() + n.Use(negronilogrus.NewMiddleware()) + n.UseFunc(serverHandler.rejectInsecureRequests) + n.UseHandler(router) + + var srv = http.Server{ + Addr: c.GetAddress(), + Handler: n, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{ + getOrCreateTLSCertificate(cmd, c), + }, + }, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 10, + } + + var err error + logrus.Infof("Setting up http server on %s", c.GetAddress()) + if ok, _ := cmd.Flags().GetBool("dangerous-force-http"); ok { + logrus.Warnln("HTTPS disabled. Never do this in production.") + err = srv.ListenAndServe() + } else if c.AllowTLSTermination != "" { + logrus.Infoln("TLS termination enabled, disabling https.") + err = srv.ListenAndServe() + } else { + err = srv.ListenAndServeTLS("", "") + } + pkg.Must(err, "Could not start server: %s %s.", err) + } +} + type Handler struct { Clients *client.Handler Connections *connection.Handler @@ -23,22 +75,25 @@ type Handler struct { OAuth2 *oauth2.Handler Policy *policy.Handler Warden *warden.WardenHandler + Config *config.Config } -func (h *Handler) Start(c *config.Config, router *httprouter.Router) { +func (h *Handler) registerRoutes(router *httprouter.Router) { + c := h.Config ctx := c.Context() - // Set up warden + // Set up dependencies + injectJWKManager(c) clientsManager := newClientManager(c) injectFositeStore(c, clientsManager) + oauth2Provider := newOAuth2Provider(c, ctx.KeyManager) + + // set up warden ctx.Warden = &warden.LocalWarden{ Warden: &ladon.Ladon{ Manager: ctx.LadonManager, }, - TokenValidator: &core.CoreValidator{ - AccessTokenStrategy: ctx.FositeStrategy, - AccessTokenStorage: ctx.FositeStore, - }, + OAuth2: oauth2Provider, Issuer: c.Issuer, AccessTokenLifespan: c.GetAccessTokenLifespan(), } @@ -48,7 +103,7 @@ func (h *Handler) Start(c *config.Config, router *httprouter.Router) { h.Keys = newJWKHandler(c, router) h.Connections = newConnectionHandler(c, router) h.Policy = newPolicyHandler(c, router) - h.OAuth2 = newOAuth2Handler(c, router, h.Keys.Manager) + h.OAuth2 = newOAuth2Handler(c, router, ctx.KeyManager, oauth2Provider) h.Warden = warden.NewHandler(c, router) // Create root account if new install @@ -58,62 +113,19 @@ func (h *Handler) Start(c *config.Config, router *httprouter.Router) { h.createRootIfNewInstall(c) } -func (h *Handler) createRS256KeysIfNotExist(c *config.Config, set, lookup string) { - ctx := c.Context() - generator := jwk.RS256Generator{} - - if _, err := ctx.KeyManager.GetKey(set, lookup); errors.Is(err, pkg.ErrNotFound) { - logrus.Warnf("Key pair for signing %s is missing. Creating new one.", set) - - keys, err := generator.Generate("") - pkg.Must(err, "Could not generate %s key: %s", set, err) - - err = ctx.KeyManager.AddKeySet(set, keys) - pkg.Must(err, "Could not persist %s key: %s", set, err) - } -} - -func (h *Handler) createRootIfNewInstall(c *config.Config) { - ctx := c.Context() - - clients, err := h.Clients.Manager.GetClients() - pkg.Must(err, "Could not fetch client list: %s", err) - if len(clients) != 0 { +func (h *Handler) rejectInsecureRequests(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if r.TLS != nil || h.Config.ForceHTTP { + next.ServeHTTP(rw, r) return } - rs, err := pkg.GenerateSecret(16) - pkg.Must(err, "Could notgenerate secret because %s", err) - secret := string(rs) - - logrus.Warn("No clients were found. Creating a temporary root client...") - root := &client.Client{ - Name: "This temporary client is generated by hydra and is granted all of hydra's administrative privileges. It must be removed when everything is set up.", - ResponseTypes: []string{"id_token", "code", "token"}, - GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}, - GrantedScopes: []string{"hydra", "core", "openid", "offline"}, - RedirectURIs: []string{"http://localhost:4445/callback"}, - Secret: secret, + if err := h.Config.DoesRequestSatisfyTermination(r); err == nil { + next.ServeHTTP(rw, r) + return + } else { + logrus.WithError(err).Warnln("Could not serve http connection") } - err = h.Clients.Manager.CreateClient(root) - pkg.Must(err, "Could not create temporary root because %s", err) - err = ctx.LadonManager.Create(&ladon.DefaultPolicy{ - Description: "This is a policy created by hydra and issued to the first client. It grants all of hydra's administrative privileges to the client and enables the client_credentials response type.", - Subjects: []string{root.GetID()}, - Effect: ladon.AllowAccess, - Resources: []string{"rn:hydra:<.*>"}, - Actions: []string{"<.*>"}, - }) - pkg.Must(err, "Could not create admin policy because %s", err) - - c.Lock() - c.ClientID = root.ID - c.ClientSecret = string(secret) - c.Unlock() - - logrus.Warn("Temporary root client created.") - logrus.Warnf("client_id: %s", root.GetID()) - logrus.Warnf("client_secret: %s", string(secret)) - logrus.Warn("The root client must be removed in production. The root's credentials could be accidentally logged.") + ans := new(herodot.JSON) + ans.WriteErrorCode(context.Background(), rw, r, http.StatusBadGateway, errors.New("Can not serve request over insecure http")) } diff --git a/cmd/server/handler_jwk_factory.go b/cmd/server/handler_jwk_factory.go index d5679bee050..8ab89facfd8 100644 --- a/cmd/server/handler_jwk_factory.go +++ b/cmd/server/handler_jwk_factory.go @@ -11,13 +11,8 @@ import ( r "gopkg.in/dancannon/gorethink.v2" ) -func newJWKHandler(c *config.Config, router *httprouter.Router) *jwk.Handler { +func injectJWKManager(c *config.Config) { ctx := c.Context() - h := &jwk.Handler{ - H: &herodot.JSON{}, - W: ctx.Warden, - } - h.SetRoutes(router) switch con := ctx.Connection.(type) { case *config.MemoryConnection: @@ -42,7 +37,15 @@ func newJWKHandler(c *config.Config, router *httprouter.Router) *jwk.Handler { default: logrus.Fatalf("Unknown connection type.") } +} - h.Manager = ctx.KeyManager +func newJWKHandler(c *config.Config, router *httprouter.Router) *jwk.Handler { + ctx := c.Context() + h := &jwk.Handler{ + H: &herodot.JSON{}, + W: ctx.Warden, + Manager: ctx.KeyManager, + } + h.SetRoutes(router) return h } diff --git a/cmd/server/handler_oauth2_factory.go b/cmd/server/handler_oauth2_factory.go index 077759357d9..66ce26349d8 100644 --- a/cmd/server/handler_oauth2_factory.go +++ b/cmd/server/handler_oauth2_factory.go @@ -1,26 +1,14 @@ package server import ( + "fmt" "net/url" - "time" - "github.com/Sirupsen/logrus" "github.com/go-errors/errors" "github.com/julienschmidt/httprouter" "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/core" - oc "github.com/ory-am/fosite/handler/core/client" - "github.com/ory-am/fosite/handler/core/explicit" - "github.com/ory-am/fosite/handler/core/implicit" - "github.com/ory-am/fosite/handler/core/refresh" - "github.com/ory-am/fosite/handler/oidc" - oe "github.com/ory-am/fosite/handler/oidc/explicit" - "github.com/ory-am/fosite/handler/oidc/hybrid" - oi "github.com/ory-am/fosite/handler/oidc/implicit" - os "github.com/ory-am/fosite/handler/oidc/strategy" - "github.com/ory-am/fosite/hash" - "github.com/ory-am/fosite/token/jwt" + "github.com/ory-am/fosite/compose" "github.com/ory-am/hydra/client" "github.com/ory-am/hydra/config" "github.com/ory-am/hydra/internal" @@ -79,7 +67,7 @@ func injectFositeStore(c *config.Config, clients client.Manager) { ctx.FositeStore = store } -func newOAuth2Handler(c *config.Config, router *httprouter.Router, km jwk.Manager) *oauth2.Handler { +func newOAuth2Provider(c *config.Config, km jwk.Manager) fosite.OAuth2Provider { var ctx = c.Context() var store = ctx.FositeStore @@ -89,96 +77,59 @@ func newOAuth2Handler(c *config.Config, router *httprouter.Router, km jwk.Manage keys, err = new(jwk.RS256Generator).Generate("") pkg.Must(err, "Could not generate signing key for OpenID Connect") km.AddKeySet(oauth2.OpenIDConnectKeyName, keys) - logrus.Warnln("Keypair generated.") + logrus.Infoln("Keypair generated.") logrus.Warnln("WARNING: Automated key creation causes low entropy. Replace the keys as soon as possible.") } else { pkg.Must(err, "Could not fetch signing key for OpenID Connect") } rsaKey := jwk.MustRSAPrivate(jwk.First(keys.Keys)) - - idStrategy := &os.DefaultStrategy{ - RS256JWTStrategy: &jwt.RS256JWTStrategy{ - PrivateKey: rsaKey, - }, - } - - oauth2HandleHelper := &core.HandleHelper{ - AccessTokenStrategy: ctx.FositeStrategy, - AccessTokenStorage: store, - AccessTokenLifespan: c.GetAccessTokenLifespan(), - } - - oidcHelper := &oidc.IDTokenHandleHelper{IDTokenStrategy: idStrategy} - - explicitHandler := &explicit.AuthorizeExplicitGrantTypeHandler{ - AccessTokenStrategy: ctx.FositeStrategy, - RefreshTokenStrategy: ctx.FositeStrategy, - AuthorizeCodeStrategy: ctx.FositeStrategy, - AuthorizeCodeGrantStorage: store, - AuthCodeLifespan: c.GetAuthCodeLifespan(), - AccessTokenLifespan: c.GetAccessTokenLifespan(), - } - - // The OpenID Connect Authorize Code Flow. - oidcExplicit := &oe.OpenIDConnectExplicitHandler{ - OpenIDConnectRequestStorage: store, - IDTokenHandleHelper: oidcHelper, + fc := &compose.Config{ + AccessTokenLifespan: c.GetAccessTokenLifespan(), + AuthorizeCodeLifespan: c.GetAuthCodeLifespan(), + IDTokenLifespan: c.GetIDTokenLifespan(), + HashCost: c.BCryptWorkFactor, } + return compose.Compose( + fc, + store, + &compose.CommonStrategy{ + CoreStrategy: compose.NewOAuth2HMACStrategy(fc, c.GetSystemSecret()), + OpenIDConnectTokenStrategy: compose.NewOpenIDConnectStrategy(rsaKey), + }, + compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2AuthorizeImplicitFactory, + compose.OAuth2ClientCredentialsGrantFactory, + compose.OAuth2RefreshTokenGrantFactory, + compose.OpenIDConnectExplicit, + compose.OpenIDConnectHybrid, + compose.OpenIDConnectImplicit, + ) +} - implicitHandler := &implicit.AuthorizeImplicitGrantTypeHandler{ - AccessTokenStrategy: ctx.FositeStrategy, - AccessTokenStorage: store, - AccessTokenLifespan: c.GetAccessTokenLifespan(), +func newOAuth2Handler(c *config.Config, router *httprouter.Router, km jwk.Manager, o fosite.OAuth2Provider) *oauth2.Handler { + if c.ConsentURL == "" { + proto := "https" + if c.ForceHTTP { + proto = "http" + } + host := "localhost" + if c.BindHost != "" { + host = c.BindHost + } + c.ConsentURL = fmt.Sprintf("%s://%s:%d/oauth2/consent", proto, host, c.BindPort) } - consentURL, err := url.Parse(c.ConsentURL) - pkg.Must(err, "Could not parse consent url.") + pkg.Must(err, "Could not parse consent url %s.", c.ConsentURL) handler := &oauth2.Handler{ - OAuth2: &fosite.Fosite{ - Store: store, - MandatoryScope: "core", - AuthorizeEndpointHandlers: fosite.AuthorizeEndpointHandlers{ - explicitHandler, - implicitHandler, - oidcExplicit, - &oi.OpenIDConnectImplicitHandler{ - IDTokenHandleHelper: oidcHelper, - AuthorizeImplicitGrantTypeHandler: implicitHandler, - }, - &hybrid.OpenIDConnectHybridHandler{ - IDTokenHandleHelper: oidcHelper, - AuthorizeExplicitGrantTypeHandler: explicitHandler, - AuthorizeImplicitGrantTypeHandler: implicitHandler, - }, - }, - TokenEndpointHandlers: fosite.TokenEndpointHandlers{ - explicitHandler, - oidcExplicit, - &refresh.RefreshTokenGrantHandler{ - AccessTokenStrategy: ctx.FositeStrategy, - RefreshTokenStrategy: ctx.FositeStrategy, - RefreshTokenGrantStorage: store, - AccessTokenLifespan: c.GetAccessTokenLifespan(), - }, - &oc.ClientCredentialsGrantHandler{ - HandleHelper: oauth2HandleHelper, - }, - }, - AuthorizedRequestValidators: fosite.AuthorizedRequestValidators{ - &core.CoreValidator{ - AccessTokenStrategy: ctx.FositeStrategy, - AccessTokenStorage: store, - }, - }, - Hasher: &hash.BCrypt{}, - }, + ForcedHTTP: c.ForceHTTP, + OAuth2: o, Consent: &oauth2.DefaultConsentStrategy{ Issuer: c.Issuer, KeyManager: km, - DefaultChallengeLifespan: time.Hour, - DefaultIDTokenLifespan: time.Hour * 24, + DefaultChallengeLifespan: c.GetChallengeTokenLifespan(), + DefaultIDTokenLifespan: c.GetIDTokenLifespan(), }, ConsentURL: *consentURL, } diff --git a/cmd/server/handler_test.go b/cmd/server/handler_test.go index ac3b0189d2e..3c1b9014da2 100644 --- a/cmd/server/handler_test.go +++ b/cmd/server/handler_test.go @@ -9,6 +9,8 @@ import ( func TestStart(t *testing.T) { router := httprouter.New() - h := &Handler{} - h.Start(&config.Config{}, router) + h := &Handler{ + Config: &config.Config{}, + } + h.registerRoutes(router) } diff --git a/cmd/server/helper_cert.go b/cmd/server/helper_cert.go new file mode 100644 index 00000000000..3d637ed96ba --- /dev/null +++ b/cmd/server/helper_cert.go @@ -0,0 +1,150 @@ +package server + +import ( + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" + + "github.com/Sirupsen/logrus" + "github.com/go-errors/errors" + "github.com/ory-am/hydra/config" + "github.com/ory-am/hydra/jwk" + "github.com/ory-am/hydra/pkg" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/square/go-jose" +) + +const ( + tlsKeyName = "hydra.tls" +) + +func loadCertificateFromFile(cmd *cobra.Command) *tls.Certificate { + keyPath := viper.GetString("HTTPS_TLS_KEY_PATH") + certPath := viper.GetString("HTTPS_TLS_CERT_PATH") + if kp, _ := cmd.Flags().GetString("https-tls-key-path"); kp != "" { + keyPath = kp + } else if cp, _ := cmd.Flags().GetString("https-tls-cert-path"); cp != "" { + certPath = cp + } else if keyPath == "" || certPath == "" { + return nil + } + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + logrus.Warn("Could not load x509 key pair: %s", cert) + return nil + } + return &cert +} + +func loadCertificateFromEnv() *tls.Certificate { + keyString := viper.GetString("HTTPS_TLS_KEY") + certString := viper.GetString("HTTPS_TLS_CERT") + if keyString == "" || certString == "" { + return nil + } + + var cert tls.Certificate + var err error + if cert, err = tls.X509KeyPair([]byte(certString), []byte(keyString)); err != nil { + logrus.Warn("Could not parse x509 key pair from env: %s", cert) + return nil + } + + return &cert +} + +func getOrCreateTLSCertificate(cmd *cobra.Command, c *config.Config) tls.Certificate { + if cert := loadCertificateFromFile(cmd); cert != nil { + return *cert + } else if cert := loadCertificateFromEnv(); cert != nil { + return *cert + } + + ctx := c.Context() + keys, err := ctx.KeyManager.GetKey(tlsKeyName, "private") + if errors.Is(err, pkg.ErrNotFound) { + logrus.Warn("No TLS Key / Certificate for HTTPS found. Generating self-signed certificate.") + + keys, err = new(jwk.ECDSA256Generator).Generate("") + pkg.Must(err, "Could not generate key: %s", err) + + cert, err := createSelfSignedCertificate(jwk.First(keys.Key("private")).Key) + pkg.Must(err, "Could not create X509 PEM Key Pair: %s", err) + + private := jwk.First(keys.Key("private")) + private.Certificates = []*x509.Certificate{cert} + keys = &jose.JsonWebKeySet{ + Keys: []jose.JsonWebKey{ + *private, + *jwk.First(keys.Key("public")), + }, + } + + err = ctx.KeyManager.AddKeySet(tlsKeyName, keys) + pkg.Must(err, "Could not persist key: %s", err) + } else { + pkg.Must(err, "Could not retrieve key: %s", err) + } + + private := jwk.First(keys.Key("private")) + block, err := jwk.PEMBlockForKey(private.Key) + if err != nil { + pkg.Must(err, "Could not encode key to PEM: %s", err) + } + + if len(private.Certificates) == 0 { + logrus.Fatal("TLS certificate chain can not be empty") + } + + pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: private.Certificates[0].Raw}) + pemKey := pem.EncodeToMemory(block) + cert, err := tls.X509KeyPair(pemCert, pemKey) + pkg.Must(err, "Could not decode certificate: %s", err) + + return cert +} + +func createSelfSignedCertificate(key interface{}) (cert *x509.Certificate, err error) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return cert, errors.Errorf("Failed to generate serial number: %s", err) + } + + certificate := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Hydra"}, + CommonName: "Hydra", + }, + Issuer: pkix.Name{ + Organization: []string{"Hydra"}, + CommonName: "Hydra", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 7), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + certificate.IsCA = true + certificate.KeyUsage |= x509.KeyUsageCertSign + certificate.DNSNames = append(certificate.DNSNames, "localhost") + der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, publicKey(key), key) + if err != nil { + return cert, errors.Errorf("Failed to create certificate: %s", err) + } + + cert, err = x509.ParseCertificate(der) + if err != nil { + return cert, errors.Errorf("Failed to encode private key: %s", err) + } + return cert, nil +} diff --git a/cmd/server/helper_client.go b/cmd/server/helper_client.go new file mode 100644 index 00000000000..2c0f8c9c9eb --- /dev/null +++ b/cmd/server/helper_client.go @@ -0,0 +1,70 @@ +package server + +import ( + "os" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/ory-am/hydra/client" + "github.com/ory-am/hydra/config" + "github.com/ory-am/hydra/pkg" + "github.com/ory-am/ladon" +) + +func (h *Handler) createRootIfNewInstall(c *config.Config) { + ctx := c.Context() + + clients, err := h.Clients.Manager.GetClients() + pkg.Must(err, "Could not fetch client list: %s", err) + if len(clients) != 0 { + return + } + + rs, err := pkg.GenerateSecret(16) + pkg.Must(err, "Could notgenerate secret because %s", err) + secret := string(rs) + + id := "" + forceRoot := os.Getenv("FORCE_ROOT_CLIENT_CREDENTIALS") + if forceRoot != "" { + credentials := strings.Split(forceRoot, ":") + if len(credentials) == 2 { + id = credentials[0] + secret = credentials[1] + } else { + logrus.Warnln("You passed malformed root client credentials, falling back to random values.") + } + } + + logrus.Warn("No clients were found. Creating a temporary root client...") + root := &client.Client{ + ID: id, + Name: "This temporary client is generated by hydra and is granted all of hydra's administrative privileges. It must be removed when everything is set up.", + ResponseTypes: []string{"id_token", "code", "token"}, + GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}, + Scopes: "hydra openid offline", + RedirectURIs: []string{"http://localhost:4445/callback"}, + Secret: secret, + } + + err = h.Clients.Manager.CreateClient(root) + pkg.Must(err, "Could not create temporary root because %s", err) + err = ctx.LadonManager.Create(&ladon.DefaultPolicy{ + Description: "This is a policy created by hydra and issued to the first client. It grants all of hydra's administrative privileges to the client and enables the client_credentials response type.", + Subjects: []string{root.GetID()}, + Effect: ladon.AllowAccess, + Resources: []string{"rn:hydra:<.*>"}, + Actions: []string{"<.*>"}, + }) + pkg.Must(err, "Could not create admin policy because %s", err) + + c.ClientID = root.ID + c.ClientSecret = string(secret) + + logrus.Infoln("Temporary root client created.") + if forceRoot == "" { + logrus.Infoln("client_id: %s", root.GetID()) + logrus.Infoln("client_secret: %s", string(secret)) + logrus.Warn("WARNING: YOU MUST delete this client once in production, as credentials may have been leaked logfiles.") + } +} diff --git a/cmd/server/helper_keys.go b/cmd/server/helper_keys.go new file mode 100644 index 00000000000..62299a46a86 --- /dev/null +++ b/cmd/server/helper_keys.go @@ -0,0 +1,38 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/rsa" + + "github.com/Sirupsen/logrus" + "github.com/go-errors/errors" + "github.com/ory-am/hydra/config" + "github.com/ory-am/hydra/jwk" + "github.com/ory-am/hydra/pkg" +) + +func (h *Handler) createRS256KeysIfNotExist(c *config.Config, set, lookup string) { + ctx := c.Context() + generator := jwk.RS256Generator{} + + if _, err := ctx.KeyManager.GetKey(set, lookup); errors.Is(err, pkg.ErrNotFound) { + logrus.Infof("Key pair for signing %s is missing. Creating new one.", set) + + keys, err := generator.Generate("") + pkg.Must(err, "Could not generate %s key: %s", set, err) + + err = ctx.KeyManager.AddKeySet(set, keys) + pkg.Must(err, "Could not persist %s key: %s", set, err) + } +} + +func publicKey(key interface{}) interface{} { + switch k := key.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} diff --git a/cmd/token_self.go b/cmd/token_self.go index 15ff17d2f8e..aa55b113543 100644 --- a/cmd/token_self.go +++ b/cmd/token_self.go @@ -32,7 +32,6 @@ var tokenSelfCmd = &cobra.Command{ ClientSecret: c.ClientSecret, TokenURL: pkg.JoinURLStrings(c.ClusterURL, "/oauth2/token"), Scopes: []string{ - "core", "hydra", }, } diff --git a/cmd/token_user.go b/cmd/token_user.go index 4fec15eef84..d833d3ab655 100644 --- a/cmd/token_user.go +++ b/cmd/token_user.go @@ -74,7 +74,7 @@ var tokenUserCmd = &cobra.Command{ } if r.URL.Query().Get("state") != string(state) { - message := fmt.Sprintf("States do not match. Expected %s but got %s", string(state), r.URL.Query().Get("state")) + message := fmt.Sprintf("States do not match. Expected %s, got %s", string(state), r.URL.Query().Get("state")) fmt.Println(message) w.WriteHeader(http.StatusInternalServerError) @@ -107,5 +107,5 @@ var tokenUserCmd = &cobra.Command{ func init() { tokenCmd.AddCommand(tokenUserCmd) tokenUserCmd.Flags().Bool("no-open", false, "Do not open a browser window with the authorize url") - tokenUserCmd.Flags().StringSlice("scopes", []string{"core", "hydra", "offline", "openid"}, "Ask for specific scopes") + tokenUserCmd.Flags().StringSlice("scopes", []string{"hydra", "offline", "openid"}, "Ask for specific scopes") } diff --git a/cmd/token_validate.go b/cmd/token_validate.go index 65d96d1e4cc..ed34f30e243 100644 --- a/cmd/token_validate.go +++ b/cmd/token_validate.go @@ -13,5 +13,6 @@ var tokenValidatorCmd = &cobra.Command{ func init() { tokenCmd.AddCommand(tokenValidatorCmd) - tokenValidatorCmd.Flags().StringSlice("scopes", []string{"core"}, "Additionally check if scope was granted") + tokenValidatorCmd.Flags().StringSlice("scopes", []string{""}, "Additionally check if scope was granted") + tokenValidatorCmd.Flags().Bool("dry", false, "do not execute the command but show the corresponding curl command instead") } diff --git a/config/backend_connections.go b/config/backend_connections.go index 40825f75aa1..c17be4fda38 100644 --- a/config/backend_connections.go +++ b/config/backend_connections.go @@ -40,7 +40,7 @@ func (c *RethinkDBConnection) GetSession() *r.Session { } if err := pkg.Retry(time.Second*15, time.Minute*2, func() error { - logrus.Infof("Connecting with RethinkDB: %s (%s) (%s)", c.URL.String(), c.URL.Host, database) + logrus.Infof("Connecting with RethinkDB: %s@%s/%s", username, c.URL.Host, database) options := r.ConnectOpts{ Address: c.URL.Host, diff --git a/config/config.go b/config/config.go index e6b6ce35ba0..e5745ce57ae 100644 --- a/config/config.go +++ b/config/config.go @@ -1,21 +1,20 @@ package config import ( + "crypto/sha256" "crypto/tls" "fmt" "io/ioutil" + "net" "net/http" "net/url" "os" - "strconv" - "sync" + "strings" "time" - "crypto/sha256" - "github.com/Sirupsen/logrus" "github.com/go-errors/errors" - "github.com/ory-am/fosite/handler/core/strategy" + foauth2 "github.com/ory-am/fosite/handler/oauth2" "github.com/ory-am/fosite/hash" "github.com/ory-am/fosite/token/hmac" "github.com/ory-am/hydra/pkg" @@ -30,85 +29,107 @@ import ( ) type Config struct { - BindPort int `mapstructure:"port" yaml:"port,omitempty"` - - BindHost string `mapstructure:"host" yaml:"host,omitempty"` - - Issuer string `mapstructure:"issuer" yaml:"issuer,omitempty"` - - SystemSecret []byte `mapstructure:"system_secret" yaml:"-"` - - DatabaseURL string `mapstructure:"database_url" yaml:"database_url,omitempty"` - - ConsentURL string `mapstructure:"consent_url" yaml:"consent_url,omitempty"` - - ClusterURL string `mapstructure:"cluster_url" yaml:"cluster_url,omitempty"` - - ClientID string `mapstructure:"client_id" yaml:"client_id,omitempty"` - - ClientSecret string `mapstructure:"client_secret" yaml:"client_secret,omitempty"` + // These are used by client commands + ClusterURL string `mapstructure:"CLUSTER_URL" yaml:"cluster_url"` + ClientID string `mapstructure:"CLIENT_ID" yaml:"client_id,omitempty"` + ClientSecret string `mapstructure:"CLIENT_SECRET" yaml:"client_secret,omitempty"` + + // These are used by the host command + BindPort int `mapstructure:"PORT" yaml:"-"` + BindHost string `mapstructure:"HOST" yaml:"-"` + Issuer string `mapstructure:"ISSUER" yaml:"-"` + SystemSecret string `mapstructure:"SYSTEM_SECRET" yaml:"-"` + DatabaseURL string `mapstructure:"DATABASE_URL" yaml:"-"` + ConsentURL string `mapstructure:"CONSENT_URL" yaml:"-"` + AllowTLSTermination string `mapstructure:"HTTPS_ALLOW_TERMINATION_FROM" yaml:"-"` + BCryptWorkFactor int `mapstructure:"BCRYPT_COST" yaml:"-"` + AccessTokenLifespan string `mapstructure:"ACCESS_TOKEN_LIFESPAN" yaml:"-"` + AuthCodeLifespan string `mapstructure:"AUTH_CODE_LIFESPAN" yaml:"-"` + IDTokenLifespan string `mapstructure:"ID_TOKEN_LIFESPAN" yaml:"-"` + ChallengeTokenLifespan string `mapstructure:"CHALLENGE_TOKEN_LIFESPAN" yaml:"-"` + ForceHTTP bool `yaml:"-"` + + cluster *url.URL `yaml:"-"` + oauth2Client *http.Client `yaml:"-"` + context *Context `yaml:"-"` +} - ForceHTTP bool `mapstructure:"foolishly_force_http" yaml:"-"` +func matchesRange(r *http.Request, ranges []string) error { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return errors.New(err) + } - Dry *bool `mapstructure:"-" yaml:"-"` + for _, rn := range ranges { + _, cidr, err := net.ParseCIDR(rn) + if err != nil { + return errors.New(err) + } + addr := net.ParseIP(ip) + if cidr.Contains(addr) { + return nil + } + } + return errors.New("Remote address does not match any cidr ranges") +} - AccessTokenLifespan time.Duration - AuthCodeLifespan time.Duration +func (c *Config) DoesRequestSatisfyTermination(r *http.Request) error { + if c.AllowTLSTermination == "" { + return errors.New("TLS termination is not enabled") + } - cluster *url.URL + ranges := strings.Split(c.AllowTLSTermination, ",") + if err := matchesRange(r, ranges); err != nil { + return err + } - oauth2Client *http.Client + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + return errors.New("X-Forwarded-Proto header is missing") + } else if proto != "https" { + return errors.Errorf("Expected X-Forwarded-Proto header to be https, got %s", proto) + } - context *Context + return nil +} - sync.Mutex +func (c *Config) GetChallengeTokenLifespan() time.Duration { + d, err := time.ParseDuration(c.ChallengeTokenLifespan) + if err != nil { + logrus.Warnf("Could not parse challenge token lifespan value (%s). Defaulting to 10m", c.AccessTokenLifespan) + return time.Minute * 10 + } + return d } func (c *Config) GetAccessTokenLifespan() time.Duration { - if c.AuthCodeLifespan == 0 { + d, err := time.ParseDuration(c.AccessTokenLifespan) + if err != nil { + logrus.Warnf("Could not parse access token lifespan value (%s). Defaulting to 1h", c.AccessTokenLifespan) return time.Hour } - return c.AccessTokenLifespan + return d } func (c *Config) GetAuthCodeLifespan() time.Duration { - if c.AuthCodeLifespan == 0 { + d, err := time.ParseDuration(c.AuthCodeLifespan) + if err != nil { + logrus.Warnf("Could not parse auth code lifespan value (%s). Defaulting to 10m", c.AuthCodeLifespan) return time.Minute * 10 } - return c.AuthCodeLifespan + return d } -func (c *Config) GetClusterURL() string { - c.Lock() - defer c.Unlock() - - if c.ClusterURL == "" { - bindHost := c.BindHost - if bindHost == "" { - bindHost = "localhost" - } - - schema := "https" - if c.ForceHTTP { - schema = "http" - } - - port := strconv.Itoa(c.BindPort) - if c.BindPort == 0 { - port = "4444" - } - - c.ClusterURL = schema + "://" + bindHost + ":" + port +func (c *Config) GetIDTokenLifespan() time.Duration { + d, err := time.ParseDuration(c.IDTokenLifespan) + if err != nil { + logrus.Warnf("Could not parse id token lifespan value (%s). Defaulting to 1h", c.IDTokenLifespan) + return time.Hour } - - return c.ClusterURL + return d } func (c *Config) Context() *Context { - secret := c.GetSystemSecret() - c.Lock() - defer c.Unlock() - if c.context != nil { return c.context } @@ -155,12 +176,12 @@ func (c *Config) Context() *Context { c.context = &Context{ Connection: connection, Hasher: &hash.BCrypt{ - WorkFactor: 11, + WorkFactor: c.BCryptWorkFactor, }, LadonManager: manager, - FositeStrategy: &strategy.HMACSHAStrategy{ + FositeStrategy: &foauth2.HMACSHAStrategy{ Enigma: &hmac.HMACStrategy{ - GlobalSecret: secret, + GlobalSecret: c.GetSystemSecret(), }, AccessTokenLifespan: c.GetAccessTokenLifespan(), AuthorizeCodeLifespan: c.GetAuthCodeLifespan(), @@ -171,9 +192,6 @@ func (c *Config) Context() *Context { } func (c *Config) Resolve(join ...string) *url.URL { - c.Lock() - defer c.Unlock() - if c.cluster == nil { cluster, err := url.Parse(c.ClusterURL) c.cluster = cluster @@ -188,9 +206,6 @@ func (c *Config) Resolve(join ...string) *url.URL { } func (c *Config) OAuth2Client(cmd *cobra.Command) *http.Client { - c.Lock() - defer c.Unlock() - if c.oauth2Client != nil { return c.oauth2Client } @@ -199,10 +214,7 @@ func (c *Config) OAuth2Client(cmd *cobra.Command) *http.Client { ClientID: c.ClientID, ClientSecret: c.ClientSecret, TokenURL: pkg.JoinURLStrings(c.ClusterURL, "/oauth2/token"), - Scopes: []string{ - "core", - "hydra", - }, + Scopes: []string{"hydra"}, } ctx := context.Background() @@ -216,9 +228,8 @@ func (c *Config) OAuth2Client(cmd *cobra.Command) *http.Client { _, err := oauthConfig.Token(ctx) if err != nil { fmt.Printf("Could not authenticate, because: %s\n", err) - fmt.Println("Did you forget to log on? Run `hydra connect`.") - fmt.Println("Did you run Hydra without a valid TLS certificate? Make sure to use the `--skip-tls-verify` flag.") - fmt.Println("Did you know you can skip `hydra connect` when running `hydra host --dangerous-auto-logon`? DO NOT use this flag in production!") + fmt.Println("This can have multiple reasons, like a wrong cluster or wrong credentials. To resolve this, run `hydra connect`.") + fmt.Println("You can disable TLS verification using the `--skip-tls-verify` flag.") os.Exit(1) } @@ -227,59 +238,41 @@ func (c *Config) OAuth2Client(cmd *cobra.Command) *http.Client { } func (c *Config) GetSystemSecret() []byte { - c.Lock() - defer c.Unlock() - - if len(c.SystemSecret) >= 16 { - hash := sha256.Sum256(c.SystemSecret) - c.SystemSecret = hash[:] - return c.SystemSecret + var secret = []byte(c.SystemSecret) + if len(secret) >= 16 { + hash := sha256.Sum256(secret) + secret = hash[:] + c.SystemSecret = string(secret) + return secret } - logrus.Warnf("Expected system secret to be at least %d characters long but only got %d characters.", 32, len(c.SystemSecret)) - logrus.Warnln("Generating a random system secret...") + logrus.Warnf("Expected system secret to be at least %d characters long, got %d characters.", 32, len(c.SystemSecret)) + logrus.Infoln("Generating a random system secret...") var err error - c.SystemSecret, err = pkg.GenerateSecret(32) + secret, err = pkg.GenerateSecret(32) pkg.Must(err, "Could not generate global secret: %s", err) - logrus.Warnf("Generated system secret: %s", c.SystemSecret) - logrus.Warnln("Do not auto-generate system secrets in production.") - hash := sha256.Sum256(c.SystemSecret) - c.SystemSecret = hash[:] - return c.SystemSecret + logrus.Infof("Generated system secret: %s", secret) + hash := sha256.Sum256(secret) + secret = hash[:] + c.SystemSecret = string(secret) + logrus.Warnln("WARNING: DO NOT generate system secrets in production. The secret will be leaked to the logs.") + return secret } func (c *Config) GetAddress() string { - c.Lock() - defer c.Unlock() - - if c.BindPort == 0 { - c.BindPort = 4444 - } return fmt.Sprintf("%s:%d", c.BindHost, c.BindPort) } -func (c *Config) GetIssuer() string { - c.Lock() - defer c.Unlock() - - if c.Issuer == "" { - c.Issuer = "hydra" - } - return c.Issuer -} - func (c *Config) Persist() error { - _ = c.GetIssuer() - _ = c.GetAddress() - _ = c.GetClusterURL() - out, err := yaml.Marshal(c) if err != nil { return errors.New(err) } + logrus.Infof("Persisting config in file %s", viper.ConfigFileUsed()) if err := ioutil.WriteFile(viper.ConfigFileUsed(), out, 0700); err != nil { return errors.Errorf(`Could not write to "%s" because: %s`, viper.ConfigFileUsed(), err) } + return nil } diff --git a/config/context.go b/config/context.go index efb036cd4bd..e93e2dce092 100644 --- a/config/context.go +++ b/config/context.go @@ -1,7 +1,7 @@ package config import ( - "github.com/ory-am/fosite/handler/core" + "github.com/ory-am/fosite/handler/oauth2" "github.com/ory-am/fosite/hash" "github.com/ory-am/hydra/firewall" "github.com/ory-am/hydra/jwk" @@ -15,7 +15,7 @@ type Context struct { Hasher hash.Hasher Warden firewall.Firewall LadonManager ladon.Manager - FositeStrategy core.CoreStrategy + FositeStrategy oauth2.CoreStrategy FositeStore pkg.FositeStorer KeyManager jwk.Manager } diff --git a/connection/handler.go b/connection/handler.go index 15ad63f5488..d486ef58b1e 100644 --- a/connection/handler.go +++ b/connection/handler.go @@ -52,7 +52,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, ps httprouter.P var conn Connection var ctx = context.Background() - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: connectionsResource, Action: "create", }, scope); err != nil { @@ -85,7 +85,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, ps httprouter.P func (h *Handler) FindLocal(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var ctx = context.Background() - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: connectionsResource, Action: "find", }, scope); err != nil { @@ -105,7 +105,7 @@ func (h *Handler) FindLocal(w http.ResponseWriter, r *http.Request, ps httproute func (h *Handler) FindRemote(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var ctx = context.Background() - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: connectionsResource, Action: "find", }, scope); err != nil { @@ -126,7 +126,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request, ps httprouter.Para var ctx = context.Background() var id = ps.ByName("id") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: fmt.Sprintf(connectionResource, id), Action: "get", }, scope); err != nil { @@ -147,7 +147,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request, ps httprouter.P var ctx = context.Background() var id = ps.ByName("id") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: fmt.Sprintf(connectionResource, id), Action: "delete", }, scope); err != nil { diff --git a/connection/manager_test.go b/connection/manager_test.go index de45c84f8c4..d95ce8c6868 100644 --- a/connection/manager_test.go +++ b/connection/manager_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/julienschmidt/httprouter" + "github.com/ory-am/dockertest" "github.com/ory-am/fosite" "github.com/ory-am/hydra/herodot" "github.com/ory-am/hydra/internal" @@ -21,7 +22,6 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/net/context" r "gopkg.in/dancannon/gorethink.v2" - "gopkg.in/ory-am/dockertest.v2" ) var connections = map[string]*Connection{ diff --git a/doc.go b/doc.go new file mode 100644 index 00000000000..0bd3c58d177 --- /dev/null +++ b/doc.go @@ -0,0 +1,9 @@ +// Package hydra is an api-only cloud native OAuth2 and OpenID Connect provider that integrates with existing authentication mechanisms: +// +// At first, there was the monolith. The monolith worked well with the bespoke authentication module. Then, the web evolved into an elastic cloud that serves thousands of different user agents in every part of the world. +// Hydra is driven by the need for a scalable, low-latency, in memory Access Control, OAuth2, and OpenID Connect layer that integrates with every identity provider you can imagine. +// Hydra is available through Docker and relies on RethinkDB for persistence. Database drivers are extensible in case you want to use RabbitMQ, MySQL, MongoDB, or some other database instead. +// Hydra is built for high throughput environments. Check out the below siege benchmark on a Macbook Pro Late 2013, connected to RethinkDB validating access tokens. +// +// The official repository is located at https://github.com/ory-am/hydra +package main \ No newline at end of file diff --git a/firewall/warden.go b/firewall/warden.go index bb12049c026..b4d242b4c3e 100644 --- a/firewall/warden.go +++ b/firewall/warden.go @@ -1,3 +1,4 @@ +// Package firewall defines an API for validating access requests. package firewall import ( @@ -8,19 +9,50 @@ import ( "golang.org/x/net/context" ) +// Context contains an access token's session data type Context struct { - Subject string `json:"sub"` - GrantedScopes []string `json:"scopes"` - Issuer string `json:"iss"` - Audience string `json:"aud"` - IssuedAt time.Time `json:"iat"` - ExpiresAt time.Time `json:"exp"` + Subject string `json:"sub"` + GrantedScopes []string `json:"scopes"` + Issuer string `json:"iss"` + Audience string `json:"aud"` + IssuedAt time.Time `json:"iat"` + ExpiresAt time.Time `json:"exp"` + Extra map[string]interface{} `json:"ext"` } +// Firewall offers various validation strategies for access tokens. type Firewall interface { - Authorized(ctx context.Context, token string, scopes ...string) (*Context, error) - HTTPAuthorized(ctx context.Context, r *http.Request, scopes ...string) (*Context, error) + Introspector - ActionAllowed(ctx context.Context, token string, accessRequest *ladon.Request, scopes ...string) (*Context, error) - HTTPActionAllowed(ctx context.Context, r *http.Request, accessRequest *ladon.Request, scopes ...string) (*Context, error) + // InspectToken checks if the given token is valid and if the requested scopes are satisfied. Returns + // a context if the token is valid and an error if not. + InspectToken(ctx context.Context, token string, scopes ...string) (*Context, error) + + // IsAllowed uses policies to return nil if the access request can be fulfilled or an error if not. + IsAllowed(ctx context.Context, accessRequest *ladon.Request) error + + // TokenAllowed uses policies and a token to return a context and no error if the access request can be fulfilled or an error if not. + TokenAllowed(ctx context.Context, token string, accessRequest *ladon.Request, scopes ...string) (*Context, error) + + // TokenFromRequest returns an access token from the HTTP Authorization header. + TokenFromRequest(r *http.Request) string +} + +// Introspection contains an access token's session data as specified by IETF RFC 7662. +type Introspection struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + ClientID string `json:"client_id,omitempty"` + Subject string `json:"sub,omitempty"` + ExpiresAt int64 `json:"exp,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Username int64 `json:"username,omitempty"` + Audience string `json:"aud,omitempty"` + Issuer string `json:"iss,omitempty"` +} + +// Introspector is capable of introspecting an access token according to IETF RFC 7662. +type Introspector interface { + IntrospectToken(ctx context.Context, token string) (*Introspection, error) } diff --git a/glide.lock b/glide.lock index 544672880fe..e070d55a8a6 100644 --- a/glide.lock +++ b/glide.lock @@ -1,24 +1,24 @@ -hash: 175acff756341dd7fa75a3e1e2e1a042a957a1b2482f017ae0bb38a2e8b9c625 -updated: 2016-07-24T16:54:50.0589572+02:00 +hash: ebc1878cf8d6949bda749aa41fd3f5272edcdee5e7dcae7ca2bfce4f6af02511 +updated: 2016-08-09T10:22:22.712279877+02:00 imports: - name: github.com/asaskevich/govalidator - version: 593d64559f7600f29581a3ee42177f5dbded27a9 + version: 7664702784775e51966f0885f5cd27435916517b - name: github.com/BurntSushi/toml version: 99064174e013895bbd9b025c31100bd1d9b590ca - name: github.com/cenk/backoff version: cdf48bbc1eb78d1349cbda326a4a037f7ba565c6 - name: github.com/davecgh/go-spew - version: 5215b55f46b2b919f50a1df0eaa5886afe4e3b3d + version: 2df174808ee097f90d259e432cc04442cf60be21 subpackages: - spew - name: github.com/dgrijalva/jwt-go - version: 01aeca54ebda6e0fbfafd0a524d234159c05ec20 + version: 268038b363c7a8d7306b8e35bf77a1fde4b0c402 - name: github.com/fsnotify/fsnotify version: a8a77c9133d2d6fd8334f3260d06f60e8d80a5fb - name: github.com/go-errors/errors version: a41850380601eeb43f4350f7d17c6bbd8944aaf8 - name: github.com/golang/protobuf - version: 874264fbbb43f4d91e999fecb4b40143ed611400 + version: c3cefd437628a0b7d31b34fe44b3a7a540e98527 subpackages: - proto - name: github.com/hailocab/go-hostpool @@ -37,88 +37,100 @@ imports: - name: github.com/inconshreveable/mousetrap version: 76626ae9c91c4f2a10f34cad8ce83ea42c93bb75 - name: github.com/julienschmidt/httprouter - version: fb79d6a91d3e4a9ecb6d945b218d78fc0d9b1939 + version: 8c199fb6259ffc1af525cc3ad52ee60ba8359669 +- name: github.com/kr/fs + version: 2788f0dbd16903de03cb8186e5c7d97b69ad387b - name: github.com/magiconair/properties - version: af14024f63beeb153d0048591b39c5788f21cc24 + version: b3f6dd549956e8a61ea4a686a1c02a33d5bdda4b +- name: github.com/meatballhat/negroni-logrus + version: 7c570a907cfc69cdc004ad506c6f5e234815b936 - name: github.com/mitchellh/mapstructure - version: 21a35fb16463dfb7c8eee579c65d995d95e64d1e + version: ca63d7c062ee3c9f34db231e352b60012b4fd0c1 - name: github.com/moul/http2curl version: b1479103caacaa39319f75e7f57fc545287fca0d - name: github.com/oleiade/reflections - version: 632977f98cd34d217c4b57d0840ec188b3d3dcaf + version: ec27669d960a245738b87ffa688dac28fa288c33 - name: github.com/ory-am/common - version: 930cc805232909c38f2e68310b1e21f71b056d59 + version: d93c852f2d09c219fd058756caf67bbdf8cf4be4 subpackages: - pkg - rand/sequence - compiler + - env - name: github.com/ory-am/fosite - version: 4d0a5450dd3b44e44f5169f90b3591566a6eef1d - subpackages: - - fosite-example/store - - handler/core - - handler/core/client - - handler/core/explicit - - handler/core/implicit - - handler/core/refresh - - handler/core/strategy - - handler/oidc - - handler/oidc/explicit - - handler/oidc/hybrid - - handler/oidc/implicit - - handler/oidc/strategy + version: 66b53a903c03950ac5180dc30c3f69e477344205 + subpackages: + - compose + - fosite-example/pkg + - handler/oauth2 + - handler/openid - hash - token/hmac - token/jwt - rand - name: github.com/ory-am/ladon - version: 940d26e2cc46679ee472225e84ca353ff0fdb43e + version: 67845728bf072d2b3f050cb415ece9a54ec6a546 +- name: github.com/parnurzeal/gorequest + version: b64673b971a1742b8ba91f228f1c029632d4b686 - name: github.com/pborman/uuid - version: c55201b036063326c5b1b89ccfe45a184973d073 + version: a97ce2ca70fa5a848076093f05e639a89ca34d06 - name: github.com/pkg/errors - version: 1d2e60385a13aaa66134984235061c2f9302520e + version: 01fa4104b9c248c8945d14d9f128454d5b28d595 - name: github.com/pkg/profile - version: 7b053ad66e2a49baca9cc97b982dcea0e182bda4 + version: 1c16f117a3ab788fdf0e334e623b8bccf5679866 +- name: github.com/pkg/sftp + version: a71e8f580e3b622ebff585309160b1cc549ef4d2 - name: github.com/pmezard/go-difflib version: d8ed2627bdf02c080bf22230dbb337003b7aba2d subpackages: - difflib - name: github.com/Sirupsen/logrus - version: a283a10442df8dc09befd873fab202bf8a253d6a + version: 4b6ea7319e214d98c938f12692336f7ca9348d6b +- name: github.com/spf13/afero + version: cc9c21814bb945440253108c4d3c65c85aac3c68 + subpackages: + - mem + - sftp - name: github.com/spf13/cast - version: 27b586b42e29bec072fe7379259cc719e1289da6 + version: e31f36ffc91a2ba9ddb72a4b6a607ff9b3d3cb63 - name: github.com/spf13/cobra - version: f62e98d28ab7ad31d707ba837a966378465c7b57 + version: 7c674d9e72017ed25f6d2b5e497a1368086b6a6f - name: github.com/spf13/jwalterweatherman version: 33c24e77fb80341fe7130ee7c594256ff08ccc46 - name: github.com/spf13/pflag - version: 1560c1005499d61b80f865c04d39ca7505bf7f0b + version: f676131e2660dc8cd88de99f7486d34aa8172635 - name: github.com/spf13/viper - version: b53595fb56a492ecef90ee0457595a999eb6ec15 + version: 346299ea79e446ebdddb834371ceba2e5926b732 - name: github.com/square/go-jose - version: f7dab0d84e417e827829f248ef197af7d87c714c + version: a3927f83df1b1516f9e9dec71839c93e6bcf1db0 subpackages: - json - - cipher - name: github.com/stretchr/testify - version: d77da356e56a7428ad25149ca77381849a6a5232 + version: f390dcf405f7b83c997eac1b06768bb9f44dec18 subpackages: - assert - require - name: github.com/toqueteos/webbrowser version: 21fc9f95c83442fd164094666f7cb4f9fdd56cd6 +- name: github.com/urfave/negroni + version: fde5e16d32adc7ad637e9cd9ad21d4ebc6192535 - name: golang.org/x/crypto - version: 911fafb28f4ee7c7bd483539a6c96190bbbccc3f + version: e0d166c33c321d0ff863f459a5882096e334f508 subpackages: - bcrypt - pbkdf2 - blowfish + - ssh + - curve25519 + - ed25519 + - ed25519/internal/edwards25519 - name: golang.org/x/net - version: 4d38db76854b199960801a1734443fd02870d7e1 + version: 075e191f18186a8ff2becaf64478e30f4545cdad subpackages: - context + - publicsuffix - name: golang.org/x/oauth2 - version: 1364adb2c63445016c5ed4518fc71f6a3cda6169 + version: 04e1573abc896e70388bd387a69753c378d46466 subpackages: - clientcredentials - internal @@ -126,8 +138,13 @@ imports: version: a646d33e2ee3172a661fc09bca23bb4889a41bc8 subpackages: - unix +- name: golang.org/x/text + version: 2910a502d2bf9e43193af9d68ca516529614eed3 + subpackages: + - transform + - unicode/norm - name: google.golang.org/appengine - version: 267c27e7492265b84fc6719503b14a1e17975d79 + version: b4728023490a62e70ba739ff62aa65ffcca84210 subpackages: - urlfetch - internal @@ -137,17 +154,32 @@ imports: - internal/log - internal/remote_api - name: gopkg.in/dancannon/gorethink.v2 - version: c5ed2858e1bf5840d4d1e8897467dd433a80a64d + version: 27d3045458910e2fc56025a0b52caaaa96414a26 subpackages: - encoding - ql2 - types -- name: gopkg.in/dgrijalva/jwt-go.v2 - version: 268038b363c7a8d7306b8e35bf77a1fde4b0c402 - name: gopkg.in/fatih/pool.v2 version: 20a0a429c5f93de45c90f5f09ea297c25e0929b3 +- name: gopkg.in/square/go-jose.v1 + version: a3927f83df1b1516f9e9dec71839c93e6bcf1db0 + subpackages: + - cipher + - json - name: gopkg.in/tylerb/graceful.v1 version: c838c13b2beeea4f4f54496da96a3a6ae567c37a - name: gopkg.in/yaml.v2 version: e4d366fc3c7938e2958e662b4258c7a89e1f0e3e -devImports: [] +testImports: +- name: github.com/go-sql-driver/mysql + version: 0b58b37b664c21f3010e836f1b931e1d0b0b0685 +- name: github.com/gorilla/context + version: 1ea25387ff6f684839d82767c1733ff4d4d15d0a +- name: github.com/gorilla/mux + version: 0eeaf8392f5b04950925b8a69fe70f110fa7cbfc +- name: github.com/lib/pq + version: 80f8150043c80fb52dee6bc863a709cdac7ec8f8 + subpackages: + - oid +- name: github.com/ory-am/dockertest + version: 1b35e25f4895dff0155ac7b67f69f9aa3a275a76 diff --git a/glide.yaml b/glide.yaml index 7f436e65122..8170488bc2f 100644 --- a/glide.yaml +++ b/glide.yaml @@ -1,46 +1,66 @@ package: github.com/ory-am/hydra import: - package: github.com/Sirupsen/logrus + version: ~0.10.0 - package: github.com/asaskevich/govalidator -- package: github.com/dgrijalva/jwt-go + version: ~4.0.0 +- package: gopkg.in/dancannon/gorethink.v2 + version: ~2.1.3 - package: github.com/go-errors/errors - package: github.com/julienschmidt/httprouter + version: ~1.1.0 +- package: github.com/meatballhat/negroni-logrus +- package: github.com/moul/http2curl - package: github.com/ory-am/common subpackages: - pkg - rand/sequence - package: github.com/ory-am/fosite + version: ~0.2.3 subpackages: - - fosite-example/store - - handler/core - - handler/core/client - - handler/core/explicit - - handler/core/implicit - - handler/core/refresh - - handler/core/strategy - - handler/oidc - - handler/oidc/explicit - - handler/oidc/hybrid - - handler/oidc/implicit - - handler/oidc/strategy + - compose + - fosite-example/pkg + - handler/oauth2 + - handler/openid - hash - token/hmac - token/jwt - package: github.com/ory-am/ladon + version: ~0.2.0 - package: github.com/pborman/uuid + version: ~1.0.0 +- package: github.com/pkg/errors + version: ~0.7.0 +- package: github.com/pkg/profile + version: ~1.2.0 - package: github.com/spf13/cobra - package: github.com/spf13/viper - package: github.com/square/go-jose + version: ~1.0.3 subpackages: - json - package: github.com/stretchr/testify + version: ~1.1.3 subpackages: - assert - require +- package: github.com/toqueteos/webbrowser + version: ~1.0.0 +- package: github.com/urfave/negroni + version: ~0.2.0 - package: golang.org/x/net subpackages: - context - package: golang.org/x/oauth2 subpackages: - clientcredentials +- package: github.com/dgrijalva/jwt-go + version: ~2.7.0 +- package: gopkg.in/tylerb/graceful.v1 + version: ~1.2.11 - package: gopkg.in/yaml.v2 +testImport: +- package: github.com/gorilla/mux + version: ~1.1.0 +- package: github.com/ory-am/dockertest + version: ~2.2.2 diff --git a/internal/firewall.go b/internal/firewall.go index b5355122019..e776c46a71a 100644 --- a/internal/firewall.go +++ b/internal/firewall.go @@ -5,7 +5,7 @@ import ( "time" "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/core" + foauth2 "github.com/ory-am/fosite/handler/oauth2" "github.com/ory-am/hydra/firewall" . "github.com/ory-am/hydra/oauth2" "github.com/ory-am/hydra/pkg" @@ -33,14 +33,20 @@ func NewFirewall(issuer string, subject string, scopes fosite.Arguments, p ...la return &warden.LocalWarden{ Warden: ladonWarden, - TokenValidator: &core.CoreValidator{ - AccessTokenStrategy: pkg.HMACStrategy, - AccessTokenStorage: fositeStore, + OAuth2: &fosite.Fosite{ + Store: fositeStore, + TokenValidators: fosite.TokenValidators{ + &foauth2.CoreValidator{ + CoreStrategy: pkg.HMACStrategy, + CoreStorage: fositeStore, + ScopeStrategy: fosite.HierarchicScopeStrategy, + }, + }, + ScopeStrategy: fosite.HierarchicScopeStrategy, }, Issuer: issuer, AccessTokenLifespan: time.Hour, - }, - conf.Client(oauth2.NoContext, &oauth2.Token{ + }, conf.Client(oauth2.NoContext, &oauth2.Token{ AccessToken: tokens[0][1], Expiry: time.Now().Add(time.Hour), TokenType: "bearer", diff --git a/internal/fosite_store_rethinkdb.go b/internal/fosite_store_rethinkdb.go index 8c8c0417530..858a3d993e3 100644 --- a/internal/fosite_store_rethinkdb.go +++ b/internal/fosite_store_rethinkdb.go @@ -91,7 +91,7 @@ func (s *FositeRehinkDBStore) publishInsert(table r.Term, id string, requester f ID: id, RequestedAt: requester.GetRequestedAt(), Client: requester.GetClient().(*client.Client), - Scopes: requester.GetScopes(), + Scopes: requester.GetRequestedScopes(), GrantedScopes: requester.GetGrantedScopes(), Form: requester.GetRequestForm(), Session: sess, @@ -107,8 +107,38 @@ func (s *FositeRehinkDBStore) publishDelete(table r.Term, id string) error { } return nil } + +func waitFor(i RDBItems, id string) error { + c := make(chan bool) + + go func() { + loopWait := time.Millisecond + _, ok := i[id] + for !ok { + time.Sleep(loopWait) + loopWait = loopWait * time.Duration(int64(2)) + if loopWait > time.Second { + loopWait = time.Second + } + _, ok = i[id] + } + + c <- true + }() + + select { + case <-c: + return nil + case <-time.After(time.Minute / 2): + return errors.New("Timed out waiting for write confirmation") + } +} + func (s *FositeRehinkDBStore) CreateOpenIDConnectSession(_ context.Context, authorizeCode string, requester fosite.Requester) error { - return s.publishInsert(s.IDSessionsTable, authorizeCode, requester) + if err := s.publishInsert(s.IDSessionsTable, authorizeCode, requester); err != nil { + return err + } + return waitFor(s.IDSessions, authorizeCode) } func (s *FositeRehinkDBStore) GetOpenIDConnectSession(_ context.Context, authorizeCode string, requester fosite.Requester) (fosite.Requester, error) { @@ -126,7 +156,10 @@ func (s *FositeRehinkDBStore) DeleteOpenIDConnectSession(_ context.Context, auth } func (s *FositeRehinkDBStore) CreateAuthorizeCodeSession(_ context.Context, code string, requester fosite.Requester) error { - return s.publishInsert(s.AuthorizeCodesTable, code, requester) + if err := s.publishInsert(s.AuthorizeCodesTable, code, requester); err != nil { + return err + } + return waitFor(s.AuthorizeCodes, code) } func (s *FositeRehinkDBStore) GetAuthorizeCodeSession(_ context.Context, code string, sess interface{}) (fosite.Requester, error) { @@ -145,7 +178,10 @@ func (s *FositeRehinkDBStore) DeleteAuthorizeCodeSession(_ context.Context, code } func (s *FositeRehinkDBStore) CreateAccessTokenSession(_ context.Context, signature string, requester fosite.Requester) error { - return s.publishInsert(s.AccessTokensTable, signature, requester) + if err := s.publishInsert(s.AccessTokensTable, signature, requester); err != nil { + return err + } + return waitFor(s.AccessTokens, signature) } func (s *FositeRehinkDBStore) GetAccessTokenSession(_ context.Context, signature string, sess interface{}) (fosite.Requester, error) { @@ -164,7 +200,10 @@ func (s *FositeRehinkDBStore) DeleteAccessTokenSession(_ context.Context, signat } func (s *FositeRehinkDBStore) CreateRefreshTokenSession(_ context.Context, signature string, requester fosite.Requester) error { - return s.publishInsert(s.RefreshTokensTable, signature, requester) + if err := s.publishInsert(s.RefreshTokensTable, signature, requester); err != nil { + return err + } + return waitFor(s.RefreshTokens, signature) } func (s *FositeRehinkDBStore) GetRefreshTokenSession(_ context.Context, signature string, sess interface{}) (fosite.Requester, error) { @@ -183,7 +222,10 @@ func (s *FositeRehinkDBStore) DeleteRefreshTokenSession(_ context.Context, signa } func (s *FositeRehinkDBStore) CreateImplicitAccessTokenSession(_ context.Context, code string, req fosite.Requester) error { - return s.publishInsert(s.ImplicitTable, code, req) + if err := s.publishInsert(s.ImplicitTable, code, req); err != nil { + return err + } + return waitFor(s.Implicit, code) } func (s *FositeRehinkDBStore) PersistAuthorizeCodeGrantSession(ctx context.Context, authorizeCode, accessSignature, refreshSignature string, request fosite.Requester) error { diff --git a/internal/fosite_store_test.go b/internal/fosite_store_test.go index dca21084094..7f0b6c4bfcb 100644 --- a/internal/fosite_store_test.go +++ b/internal/fosite_store_test.go @@ -8,13 +8,13 @@ import ( "github.com/Sirupsen/logrus" c "github.com/ory-am/common/pkg" + "github.com/ory-am/dockertest" "github.com/ory-am/fosite" "github.com/ory-am/hydra/client" "github.com/ory-am/hydra/pkg" "github.com/pborman/uuid" "golang.org/x/net/context" r "gopkg.in/dancannon/gorethink.v2" - "gopkg.in/ory-am/dockertest.v2" ) var rethinkManager *FositeRehinkDBStore @@ -112,8 +112,6 @@ func TestColdStartRethinkManager(t *testing.T) { err = m.CreateAccessTokenSession(ctx, id, &defaultRequest) pkg.AssertError(t, false, err) - time.Sleep(100 * time.Millisecond) - _, err = m.GetAuthorizeCodeSession(ctx, id, &testSession{}) pkg.AssertError(t, false, err) _, err = m.GetAccessTokenSession(ctx, id, &testSession{}) @@ -145,8 +143,6 @@ func TestCreateGetDeleteAuthorizeCodes(t *testing.T) { err = m.CreateAuthorizeCodeSession(ctx, "4321", &defaultRequest) pkg.AssertError(t, false, err, "%s", k) - time.Sleep(100 * time.Millisecond) - res, err := m.GetAuthorizeCodeSession(ctx, "4321", &testSession{}) pkg.RequireError(t, false, err, "%s", k) c.AssertObjectKeysEqual(t, &defaultRequest, res, "Scopes", "GrantedScopes", "Form", "Session") @@ -170,8 +166,6 @@ func TestCreateGetDeleteAccessTokenSession(t *testing.T) { err = m.CreateAccessTokenSession(ctx, "4321", &defaultRequest) pkg.AssertError(t, false, err, "%s", k) - time.Sleep(100 * time.Millisecond) - res, err := m.GetAccessTokenSession(ctx, "4321", &testSession{}) pkg.RequireError(t, false, err, "%s", k) c.AssertObjectKeysEqual(t, &defaultRequest, res, "Scopes", "GrantedScopes", "Form", "Session") @@ -195,8 +189,6 @@ func TestCreateGetDeleteOpenIDConnectSession(t *testing.T) { err = m.CreateOpenIDConnectSession(ctx, "4321", &defaultRequest) pkg.AssertError(t, false, err, "%s", k) - time.Sleep(100 * time.Millisecond) - res, err := m.GetOpenIDConnectSession(ctx, "4321", &fosite.Request{ Session: &testSession{}, }) @@ -222,8 +214,6 @@ func TestCreateGetDeleteRefreshTokenSession(t *testing.T) { err = m.CreateRefreshTokenSession(ctx, "4321", &defaultRequest) pkg.AssertError(t, false, err, "%s", k) - time.Sleep(100 * time.Millisecond) - res, err := m.GetRefreshTokenSession(ctx, "4321", &testSession{}) pkg.RequireError(t, false, err, "%s", k) c.AssertObjectKeysEqual(t, &defaultRequest, res, "Scopes", "GrantedScopes", "Form", "Session") diff --git a/jwk/handler.go b/jwk/handler.go index 61db796bd25..dcbd1ef0201 100644 --- a/jwk/handler.go +++ b/jwk/handler.go @@ -60,7 +60,7 @@ func (h *Handler) DeleteKey(w http.ResponseWriter, r *http.Request, ps httproute var setName = ps.ByName("set") var keyName = ps.ByName("key") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + setName + ":" + keyName, Action: "delete", }, "hydra.keys.delete"); err != nil { @@ -80,7 +80,7 @@ func (h *Handler) DeleteKeySet(w http.ResponseWriter, r *http.Request, ps httpro var ctx = context.Background() var setName = ps.ByName("set") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + setName, Action: "delete", }, "hydra.keys.delete"); err != nil { @@ -101,7 +101,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, ps httprouter.P var keyRequest createRequest var set = ps.ByName("set") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + set, Action: "create", }, "hydra.keys.create"); err != nil { @@ -139,7 +139,7 @@ func (h *Handler) UpdateKeySet(w http.ResponseWriter, r *http.Request, ps httpro var keySet = new(jose.JsonWebKeySet) var set = ps.ByName("set") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + set, Action: "update", }, "hydra.keys.update"); err != nil { @@ -178,7 +178,7 @@ func (h *Handler) UpdateKey(w http.ResponseWriter, r *http.Request, ps httproute return } - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + set + ":" + key.KeyID, Action: "update", }, "hydra.keys.update"); err != nil { @@ -199,7 +199,7 @@ func (h *Handler) GetKey(w http.ResponseWriter, r *http.Request, ps httprouter.P var setName = ps.ByName("set") var keyName = ps.ByName("key") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + setName + ":" + keyName, Action: "get", }, "hydra.keys.get"); err != nil { @@ -227,7 +227,7 @@ func (h *Handler) GetKeySet(w http.ResponseWriter, r *http.Request, ps httproute } for _, key := range keys.Keys { - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: "rn:hydra:keys:" + setName + ":" + key.KeyID, Action: "get", }, "hydra.keys.get"); err != nil { diff --git a/jwk/manager_rethinkdb.go b/jwk/manager_rethinkdb.go index bf6b8540e3d..d97660e9057 100644 --- a/jwk/manager_rethinkdb.go +++ b/jwk/manager_rethinkdb.go @@ -7,6 +7,8 @@ import ( "time" + "fmt" + "github.com/Sirupsen/logrus" "github.com/go-errors/errors" "github.com/ory-am/hydra/pkg" @@ -239,7 +241,7 @@ func (m *RethinkManager) ColdStart() error { for clients.Next(&raw) { pt, err := m.Cipher.Decrypt(raw.Key) if err != nil { - return errors.New(err) + return errors.New(fmt.Sprintf("Could not decrypt JSON Web Keys because: %s. This usually happens when a wrong system secret is being used", err.Error())) } if err := json.Unmarshal(pt, &key); err != nil { diff --git a/jwk/manager_test.go b/jwk/manager_test.go index db4ca6d82a0..15d83d29313 100644 --- a/jwk/manager_test.go +++ b/jwk/manager_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/julienschmidt/httprouter" + "github.com/ory-am/dockertest" "github.com/ory-am/fosite" "github.com/ory-am/hydra/herodot" "github.com/ory-am/hydra/internal" @@ -22,7 +23,6 @@ import ( "github.com/ory-am/fosite/rand" "github.com/square/go-jose" "golang.org/x/net/context" - "gopkg.in/ory-am/dockertest.v2" ) var managers = map[string]Manager{} diff --git a/main.go b/main.go index 030c463796a..7b02fd387d5 100644 --- a/main.go +++ b/main.go @@ -9,8 +9,10 @@ import ( ) func main() { - if os.Getenv("HYDRA_PROFILING") == "1" { - defer profile.Start().Stop() + if os.Getenv("PROFILING") == "cpu" { + defer profile.Start(profile.CPUProfile).Stop() + } else if os.Getenv("PROFILING") == "memory" { + defer profile.Start(profile.MemProfile).Stop() } switch os.Getenv("LOG_LEVEL") { diff --git a/oauth2/consent_strategy.go b/oauth2/consent_strategy.go index d8ade698200..c5479bf9d49 100644 --- a/oauth2/consent_strategy.go +++ b/oauth2/consent_strategy.go @@ -6,13 +6,13 @@ import ( "crypto/rsa" + "github.com/dgrijalva/jwt-go" "github.com/go-errors/errors" "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/oidc/strategy" + "github.com/ory-am/fosite/handler/openid" ejwt "github.com/ory-am/fosite/token/jwt" "github.com/ory-am/hydra/jwk" "github.com/pborman/uuid" - "gopkg.in/dgrijalva/jwt-go.v2" ) const ( @@ -66,19 +66,29 @@ func (s *DefaultConsentStrategy) ValidateResponse(a fosite.AuthorizeRequester, t a.GrantScope(scope) } + var idExt map[string]interface{} + var atExt map[string]interface{} + if ext, ok := t.Claims["id_ext"].(map[string]interface{}); ok { + idExt = ext + } + if ext, ok := t.Claims["id_ext"].(map[string]interface{}); ok { + atExt = ext + } + return &Session{ Subject: subject, - DefaultSession: &strategy.DefaultSession{ + DefaultSession: &openid.DefaultSession{ Claims: &ejwt.IDTokenClaims{ Audience: a.GetClient().GetID(), Subject: subject, Issuer: s.Issuer, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(s.DefaultIDTokenLifespan), - Extra: t.Claims, + Extra: idExt, }, Headers: &ejwt.Headers{}, }, + Extra: atExt, }, err } @@ -106,7 +116,7 @@ func (s *DefaultConsentStrategy) IssueChallenge(authorizeRequest fosite.Authoriz token := jwt.New(jwt.SigningMethodRS256) token.Claims = map[string]interface{}{ "jti": uuid.New(), - "scp": authorizeRequest.GetScopes(), + "scp": authorizeRequest.GetRequestedScopes(), "aud": authorizeRequest.GetClient().GetID(), "exp": time.Now().Add(s.DefaultChallengeLifespan).Unix(), "redir": redirectURL, diff --git a/oauth2/handler.go b/oauth2/handler.go index 3f0d125a6fd..be6b020b30e 100644 --- a/oauth2/handler.go +++ b/oauth2/handler.go @@ -7,9 +7,6 @@ import ( "github.com/go-errors/errors" "github.com/julienschmidt/httprouter" "github.com/ory-am/fosite" - csh "github.com/ory-am/fosite/handler/core/strategy" - "github.com/ory-am/fosite/handler/oidc/strategy" - "github.com/ory-am/fosite/token/jwt" "github.com/ory-am/hydra/pkg" ) @@ -18,8 +15,9 @@ const ( ) type Handler struct { - OAuth2 fosite.OAuth2Provider - Consent ConsentStrategy + OAuth2 fosite.OAuth2Provider + Consent ConsentStrategy + ForcedHTTP bool ConsentURL url.URL } @@ -28,19 +26,14 @@ func (h *Handler) SetRoutes(r *httprouter.Router) { r.POST("/oauth2/token", h.TokenHandler) r.GET("/oauth2/auth", h.AuthHandler) r.POST("/oauth2/auth", h.AuthHandler) + r.GET("/oauth2/consent", h.DefaultConsentHandler) } func (o *Handler) TokenHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - var session = Session{ - DefaultSession: &strategy.DefaultSession{ - Claims: new(jwt.IDTokenClaims), - Headers: new(jwt.Headers), - HMACSession: new(csh.HMACSession), - }, - } + var session = NewSession("") var ctx = fosite.NewContext() - accessRequest, err := o.OAuth2.NewAccessRequest(ctx, r, &session) + accessRequest, err := o.OAuth2.NewAccessRequest(ctx, r, session) if err != nil { pkg.LogError(err) o.OAuth2.WriteAccessError(w, accessRequest, err) @@ -49,6 +42,11 @@ func (o *Handler) TokenHandler(w http.ResponseWriter, r *http.Request, _ httprou if accessRequest.GetGrantTypes().Exact("client_credentials") { session.Subject = accessRequest.GetClient().GetID() + for _, scope := range accessRequest.GetRequestedScopes() { + if fosite.HierarchicScopeStrategy(accessRequest.GetClient().GetScopes(), scope) { + accessRequest.GrantScope(scope) + } + } } accessResponse, err := o.OAuth2.NewAccessResponse(ctx, r, accessRequest) @@ -105,9 +103,10 @@ func (o *Handler) AuthHandler(w http.ResponseWriter, r *http.Request, _ httprout func (o *Handler) redirectToConsent(w http.ResponseWriter, r *http.Request, authorizeRequest fosite.AuthorizeRequester) error { schema := "https" - if r.TLS == nil { + if o.ForcedHTTP { schema = "http" } + challenge, err := o.Consent.IssueChallenge(authorizeRequest, schema+"://"+r.Host+r.URL.String()) if err != nil { return err diff --git a/oauth2/handler_consent.go b/oauth2/handler_consent.go new file mode 100644 index 00000000000..65e446f2699 --- /dev/null +++ b/oauth2/handler_consent.go @@ -0,0 +1,30 @@ +package oauth2 + +import ( + "net/http" + + "github.com/Sirupsen/logrus" + "github.com/julienschmidt/httprouter" +) + +func (o *Handler) DefaultConsentHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + logrus.Warnln("It looks like no consent endpoint was set. All OAuth2 flows except client credentials will fail.") + + w.Write([]byte(` + + + Misconfigured consent endpoint + + +

+ It looks like you forgot to set the consent endpoint url, which can be set using the CONSENT_ENDPOINT + environment variable. +

+

+ If you are an administrator, please read + the guide to understand what you need to do. If you are a user, please contact the administrator. +

+ + +`)) +} diff --git a/oauth2/oauth2_auth_code_test.go b/oauth2/oauth2_auth_code_test.go index 6f2797b69b7..c13565850af 100644 --- a/oauth2/oauth2_auth_code_test.go +++ b/oauth2/oauth2_auth_code_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/dgrijalva/jwt-go" "github.com/go-errors/errors" "github.com/julienschmidt/httprouter" ejwt "github.com/ory-am/fosite/token/jwt" @@ -15,7 +16,6 @@ import ( "github.com/pborman/uuid" "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "gopkg.in/dgrijalva/jwt-go.v2" ) func TestAuthCode(t *testing.T) { @@ -39,6 +39,7 @@ func TestAuthCode(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "aud": "app-client", + "scp": []string{"hydra"}, }) pkg.RequireError(t, false, err) diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index 4469f365b24..f7c9636aab8 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -10,12 +10,8 @@ import ( "github.com/go-errors/errors" "github.com/julienschmidt/httprouter" "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/core" - "github.com/ory-am/fosite/handler/core/client" - "github.com/ory-am/fosite/handler/core/explicit" - "github.com/ory-am/fosite/handler/core/strategy" + "github.com/ory-am/fosite/compose" "github.com/ory-am/fosite/hash" - "github.com/ory-am/fosite/token/hmac" hc "github.com/ory-am/hydra/client" "github.com/ory-am/hydra/internal" "github.com/ory-am/hydra/jwk" @@ -23,7 +19,7 @@ import ( "github.com/ory-am/hydra/pkg" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" - "gopkg.in/dgrijalva/jwt-go.v2" + "github.com/dgrijalva/jwt-go" ) var hasher = &hash.BCrypt{} @@ -41,60 +37,37 @@ var store = &internal.FositeMemoryStore{ } var keyManager = &jwk.MemoryManager{} - var keyGenerator = &jwk.RS256Generator{} -var hmacStrategy = &strategy.HMACSHAStrategy{ - Enigma: &hmac.HMACStrategy{ - GlobalSecret: []byte("some-super-cool-secret-that-nobody-knows"), - }, - AuthorizeCodeLifespan: time.Hour, - AccessTokenLifespan: time.Hour, -} - -var authCodeHandler = &explicit.AuthorizeExplicitGrantTypeHandler{ - AccessTokenStrategy: hmacStrategy, - RefreshTokenStrategy: hmacStrategy, - AuthorizeCodeStrategy: hmacStrategy, - AuthorizeCodeGrantStorage: store, - AuthCodeLifespan: time.Hour, - AccessTokenLifespan: time.Hour, -} - +var fc = &compose.Config{} var handler = &Handler{ - OAuth2: &fosite.Fosite{ - Store: store, - MandatoryScope: "hydra", - AuthorizeEndpointHandlers: fosite.AuthorizeEndpointHandlers{ - authCodeHandler, + OAuth2: compose.Compose( + fc, + store, + &compose.CommonStrategy{ + CoreStrategy: compose.NewOAuth2HMACStrategy(fc, []byte("some super secret secret")), + OpenIDConnectTokenStrategy: compose.NewOpenIDConnectStrategy(pkg.MustRSAKey()), }, - TokenEndpointHandlers: fosite.TokenEndpointHandlers{ - authCodeHandler, - &client.ClientCredentialsGrantHandler{ - HandleHelper: &core.HandleHelper{ - AccessTokenStrategy: hmacStrategy, - AccessTokenStorage: store, - AccessTokenLifespan: time.Hour, - }, - }, - }, - AuthorizedRequestValidators: fosite.AuthorizedRequestValidators{}, - Hasher: hasher, - }, + compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2AuthorizeImplicitFactory, + compose.OAuth2ClientCredentialsGrantFactory, + compose.OAuth2RefreshTokenGrantFactory, + compose.OpenIDConnectExplicit, + compose.OpenIDConnectHybrid, + compose.OpenIDConnectImplicit, + ), Consent: &DefaultConsentStrategy{ - Issuer: "https://hydra.localhost", + Issuer: "http://hydra.localhost", KeyManager: keyManager, DefaultChallengeLifespan: time.Hour, DefaultIDTokenLifespan: time.Hour * 24, }, + ForcedHTTP: true, } var router = httprouter.New() - var ts *httptest.Server - var oauthConfig *oauth2.Config - var oauthClientConfig *clientcredentials.Config func init() { @@ -114,6 +87,7 @@ func init() { RedirectURIs: []string{ts.URL + "/callback"}, ResponseTypes: []string{"id_token", "code", "token"}, GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}, + Scopes: "hydra", } c, _ := url.Parse(ts.URL + "/consent") @@ -126,6 +100,7 @@ func init() { RedirectURIs: []string{ts.URL + "/callback"}, ResponseTypes: []string{"id_token", "code", "token"}, GrantTypes: []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}, + Scopes: "hydra", } oauthConfig = &oauth2.Config{ diff --git a/oauth2/session.go b/oauth2/session.go index f6dd3036808..c180c7e8069 100644 --- a/oauth2/session.go +++ b/oauth2/session.go @@ -1,23 +1,25 @@ package oauth2 import ( - csh "github.com/ory-am/fosite/handler/core/strategy" - "github.com/ory-am/fosite/handler/oidc/strategy" + "github.com/ory-am/fosite/handler/oauth2" + "github.com/ory-am/fosite/handler/openid" "github.com/ory-am/fosite/token/jwt" ) type Session struct { - Subject string `json:"sub"` - *strategy.DefaultSession `json:"idToken"` + Subject string `json:"sub"` + *openid.DefaultSession `json:"idToken"` + *oauth2.HMACSession `json:"session"` + Extra map[string]interface{} `json:"extra"` } func NewSession(subject string) *Session { return &Session{ Subject: subject, - DefaultSession: &strategy.DefaultSession{ - Claims: new(jwt.IDTokenClaims), - Headers: new(jwt.Headers), - HMACSession: new(csh.HMACSession), + DefaultSession: &openid.DefaultSession{ + Claims: new(jwt.IDTokenClaims), + Headers: new(jwt.Headers), }, + HMACSession: new(oauth2.HMACSession), } } diff --git a/pkg/errors.go b/pkg/errors.go index d4beaf4f15c..baedba906fb 100644 --- a/pkg/errors.go +++ b/pkg/errors.go @@ -1,9 +1,6 @@ package pkg import ( - "net/http" - "net/url" - log "github.com/Sirupsen/logrus" "github.com/go-errors/errors" "github.com/ory-am/hydra/herodot" @@ -11,9 +8,7 @@ import ( ) var ( - ErrNotFound = errors.New("Not found") - ErrUnauthorized = errors.New("Unauthorized") - ErrForbidden = errors.New("Forbidden") + ErrNotFound = errors.New("Not found") ) type stackTracer interface { @@ -22,20 +17,12 @@ type stackTracer interface { func LogError(err error) { if e, ok := err.(*herodot.Error); ok { - log.WithError(err).WithField("stack", e.Err.ErrorStack()).Printf("Got error.") + log.WithError(err).WithField("stack", e.Err.ErrorStack()).Infoln("An error occured") } else if e, ok := err.(*errors.Error); ok { - log.WithError(err).WithField("stack", e.ErrorStack()).Printf("Got error.") + log.WithError(err).WithField("stack", e.ErrorStack()).Infoln("An error occurred") } else if e, ok := err.(stackTracer); ok { - log.WithError(err).WithField("stack", e.StackTrace()).Printf("Got error.") + log.WithError(err).WithField("stack", e.StackTrace()).Infoln("An error occured") } else { - log.WithError(err).Printf("Got error.") + log.WithError(err).Infoln("An error occured") } } - -func ForwardToErrorHandler(w http.ResponseWriter, r *http.Request, err error, errorHandlerURL url.URL) { - q := errorHandlerURL.Query() - q.Set("error", err.Error()) - errorHandlerURL.RawQuery = q.Encode() - - http.Redirect(w, r, errorHandlerURL.String(), http.StatusFound) -} diff --git a/pkg/fosite_storer.go b/pkg/fosite_storer.go index f13244be2be..2cbf9746705 100644 --- a/pkg/fosite_storer.go +++ b/pkg/fosite_storer.go @@ -2,18 +2,15 @@ package pkg import ( "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/core" - "github.com/ory-am/fosite/handler/core/explicit" - "github.com/ory-am/fosite/handler/core/implicit" - "github.com/ory-am/fosite/handler/core/refresh" - "github.com/ory-am/fosite/handler/oidc" + "github.com/ory-am/fosite/handler/oauth2" + "github.com/ory-am/fosite/handler/openid" ) type FositeStorer interface { - core.AccessTokenStorage + oauth2.AccessTokenStorage fosite.Storage - explicit.AuthorizeCodeGrantStorage - refresh.RefreshTokenGrantStorage - implicit.ImplicitGrantStorage - oidc.OpenIDConnectRequestStorage + oauth2.AuthorizeCodeGrantStorage + oauth2.RefreshTokenGrantStorage + oauth2.ImplicitGrantStorage + openid.OpenIDConnectRequestStorage } diff --git a/pkg/helper/dry.go b/pkg/helper/dry.go new file mode 100644 index 00000000000..0f9013a29eb --- /dev/null +++ b/pkg/helper/dry.go @@ -0,0 +1,20 @@ +package helper + +import ( + "net/http" + + "github.com/go-errors/errors" + "github.com/moul/http2curl" +) + +func DoDryRequest(dry bool, req *http.Request) error { + if dry { + command, err := http2curl.GetCurlCommand(req) + if err != nil { + return errors.New(err) + } + + return errors.Errorf("Because you are using the dry option, the request will not be executed. The curl equivalent of this command is: \n\n%s\n", command) + } + return nil +} diff --git a/pkg/rsa.go b/pkg/rsa.go new file mode 100644 index 00000000000..1b7c8b89dc4 --- /dev/null +++ b/pkg/rsa.go @@ -0,0 +1,14 @@ +package pkg + +import ( + "crypto/rand" + "crypto/rsa" +) + +func MustRSAKey() *rsa.PrivateKey { + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + panic(err) + } + return key +} diff --git a/pkg/superagent.go b/pkg/superagent.go index 1a756e720cd..a9064cacf68 100644 --- a/pkg/superagent.go +++ b/pkg/superagent.go @@ -3,13 +3,11 @@ package pkg import ( "bytes" "encoding/json" - "net/http" - "io/ioutil" + "net/http" "github.com/go-errors/errors" - "github.com/moul/http2curl" - "github.com/spf13/viper" + "github.com/ory-am/hydra/pkg/helper" ) type SuperAgent struct { @@ -22,20 +20,11 @@ func NewSuperAgent(rawurl string) *SuperAgent { return &SuperAgent{ URL: rawurl, Client: http.DefaultClient, - Dry: viper.GetBool("dry"), } } func (s *SuperAgent) doDry(req *http.Request) error { - if s.Dry { - command, err := http2curl.GetCurlCommand(req) - if err != nil { - return errors.New(err) - } - - return errors.Errorf("Because you are using the dry option, the request will not be executed. You can execute this command using: \n\n%s", command) - } - return nil + return helper.DoDryRequest(s.Dry, req) } func (s *SuperAgent) Delete() error { @@ -127,16 +116,10 @@ func (s *SuperAgent) send(method string, in interface{}, out interface{}) error return errors.New(err) } - expectedStatus := http.StatusOK - if method == "POST" { - expectedStatus = http.StatusCreated - } - if resp.StatusCode != expectedStatus { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := ioutil.ReadAll(resp.Body) - return errors.Errorf("Expected status code %d, got %d.\n%s", expectedStatus, resp.StatusCode, body) - } - - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return errors.Errorf("Expected 2xx status code but got %d.\n%s", resp.StatusCode, body) + } else if err := json.NewDecoder(resp.Body).Decode(out); err != nil { body, _ := ioutil.ReadAll(resp.Body) return errors.Errorf("%s: %s", err, body) } diff --git a/pkg/test_helpers.go b/pkg/test_helpers.go index 211fdad11ed..8c2013a1574 100644 --- a/pkg/test_helpers.go +++ b/pkg/test_helpers.go @@ -2,18 +2,25 @@ package pkg import ( "testing" - "time" "github.com/go-errors/errors" - "github.com/ory-am/fosite/fosite-example/store" - "github.com/ory-am/fosite/handler/core/strategy" + "github.com/ory-am/fosite/fosite-example/pkg" + "github.com/ory-am/fosite/handler/oauth2" "github.com/ory-am/fosite/token/hmac" "github.com/ory-am/ladon" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var HMACStrategy = &oauth2.HMACSHAStrategy{ + Enigma: &hmac.HMACStrategy{ + GlobalSecret: []byte("1234567890123456789012345678901234567890"), + }, + AccessTokenLifespan: time.Hour, + AuthorizeCodeLifespan: time.Hour, +} + func RequireError(t *testing.T, expectError bool, err error, args ...interface{}) { if err != nil && !expectError { t.Logf("Unexpected error: %s\n", err.Error()) @@ -46,8 +53,8 @@ func LadonWarden(ps map[string]ladon.Policy) ladon.Warden { } } -func FositeStore() *store.Store { - return store.NewStore() +func FositeStore() *pkg.Store { + return pkg.NewStore() } func Tokens(length int) (res [][]string) { @@ -57,11 +64,3 @@ func Tokens(length int) (res [][]string) { } return res } - -var HMACStrategy = &strategy.HMACSHAStrategy{ - Enigma: &hmac.HMACStrategy{ - GlobalSecret: []byte("1234567890123456789012345678901234567890"), - }, - AccessTokenLifespan: time.Hour, - AuthorizeCodeLifespan: time.Hour, -} diff --git a/policy/handler.go b/policy/handler.go index a2957a39f30..1d8c0a53da1 100644 --- a/policy/handler.go +++ b/policy/handler.go @@ -40,7 +40,7 @@ func (h *Handler) Find(w http.ResponseWriter, r *http.Request, _ httprouter.Para h.H.WriteErrorCode(ctx, w, r, http.StatusBadRequest, errors.New("Missing query parameter subject")) } - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: policyResource, Action: "find", }, scope); err != nil { @@ -62,7 +62,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, _ httprouter.Pa } ctx := herodot.NewContext() - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: policyResource, Action: "create", }, scope); err != nil { @@ -89,7 +89,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request, _ httprouter.Pa func (h *Handler) Get(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { ctx := herodot.NewContext() - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: fmt.Sprintf(policiesResource, ps.ByName("id")), Action: "get", }, scope); err != nil { @@ -109,7 +109,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request, ps httprouter.P ctx := herodot.NewContext() id := ps.ByName("id") - if _, err := h.W.HTTPActionAllowed(ctx, r, &ladon.Request{ + if _, err := h.W.TokenAllowed(ctx, h.W.TokenFromRequest(r), &ladon.Request{ Resource: fmt.Sprintf(policiesResource, id), Action: "get", }, scope); err != nil { diff --git a/sdk/client.go b/sdk/client.go index 9c3f6facd7b..f74519650e9 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -1,4 +1,3 @@ -// Wraps hydra HTTP Manager's package sdk import ( @@ -43,7 +42,7 @@ var defaultOptions = []option{ ClusterURL(os.Getenv("HYDRA_CLUSTER_URL")), ClientID(os.Getenv("HYDRA_CLIENT_ID")), ClientSecret(os.Getenv("HYDRA_CLIENT_SECRET")), - Scopes("core", "hydra"), + Scopes("hydra"), } // Connect instantiates a new client to communicate with Hydra diff --git a/sdk/doc.go b/sdk/doc.go new file mode 100644 index 00000000000..e60e0ee2ba8 --- /dev/null +++ b/sdk/doc.go @@ -0,0 +1,27 @@ +// Package SDK offers convenience functions for Go code around Hydra's HTTP APIs. +// +// import "github.com/ory-am/hydra/sdk" +// import "github.com/ory-am/hydra/client" +// var hydra, err = sdk.Connect( +// sdk.ClientID("client-id"), +// sdk.ClientSecret("client-secret"), +// sdk.ClustURL("https://localhost:4444"), +// ) +// +// // Create a new OAuth2 client +// var newClient, err = hydra.Client.CreateClient(&client.Client{ +// ID: "deadbeef", +// Secret: "sup3rs3cret", +// RedirectURIs: []string{"http://yourapp/callback"}, +// // ... +// }) +// +// // Retrieve newly created client +// var gotClient, err = hydra.Client.GetClient(newClient.ID) +// +// // Remove the newly created client +// var err = hydra.Client.DeleteClient(newClient.ID) +// +// // Retrieve list of all clients +// var clients, err = hydra.Client.GetClients() +package sdk diff --git a/warden/doc.go b/warden/doc.go new file mode 100644 index 00000000000..caf9144758a --- /dev/null +++ b/warden/doc.go @@ -0,0 +1,9 @@ +// Package warden decides if access requests should be allowed or denied. In a scientific taxonomy, the warden +// is classified as a Policy Decision Point. THe warden's primary goal is to implement `github.com/ory-am/hydra/firewall.Firewall`. +// +// This package is structured as follows: +// * handler.go: A HTTP handler capable of validating access tokens. +// * warden_http.go: A Go API using HTTP to validate access tokens. +// * warden_local.go: A Go API using storage managers to validate access tokens. +// * warden_test.go: Functional tests all of the above. +package warden diff --git a/warden/handler.go b/warden/handler.go index 11530733a27..ab3144c713b 100644 --- a/warden/handler.go +++ b/warden/handler.go @@ -11,18 +11,18 @@ import ( "github.com/ory-am/hydra/firewall" "github.com/ory-am/hydra/herodot" "github.com/ory-am/ladon" - "golang.org/x/net/context" ) const ( - AuthorizedHandlerPath = "/warden/authorized" - AllowedHandlerPath = "/warden/allowed" + TokenValidHandlerPath = "/warden/token/valid" + TokenAllowedHandlerPath = "/warden/token/allowed" + AllowedHandlerPath = "/warden/allowed" + IntrospectPath = "/oauth2/introspect" ) type WardenHandler struct { H herodot.Herodot Warden firewall.Firewall - Ladon ladon.Warden } func NewHandler(c *config.Config, router *httprouter.Router) *WardenHandler { @@ -31,22 +31,15 @@ func NewHandler(c *config.Config, router *httprouter.Router) *WardenHandler { h := &WardenHandler{ H: &herodot.JSON{}, Warden: ctx.Warden, - Ladon: &ladon.Ladon{ - Manager: ctx.LadonManager, - }, } h.SetRoutes(router) return h } -type WardenResponse struct { - *firewall.Context -} - type WardenAuthorizedRequest struct { - Scopes []string `json:"scopes"` - Assertion string `json:"assertion"` + Scopes []string `json:"scopes"` + Token string `json:"token"` } type WardenAccessRequest struct { @@ -54,14 +47,56 @@ type WardenAccessRequest struct { *WardenAuthorizedRequest } +var notAllowed = struct { + Allowed bool `json:"allowed"` +}{Allowed: false} + +var invalid = struct { + Valid bool `json:"valid"` +}{Valid: false} + +var inactive = struct { + Active bool `json:"active"` +}{Active: false} + func (h *WardenHandler) SetRoutes(r *httprouter.Router) { - r.POST(AuthorizedHandlerPath, h.Authorized) + r.POST(TokenValidHandlerPath, h.TokenValid) + r.POST(TokenAllowedHandlerPath, h.TokenAllowed) r.POST(AllowedHandlerPath, h.Allowed) + r.POST(IntrospectPath, h.Introspect) +} + +func (h *WardenHandler) Introspect(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + ctx := herodot.NewContext() + clientCtx, err := h.Warden.InspectToken(ctx, TokenFromRequest(r)) + if err != nil { + h.H.WriteError(ctx, w, r, err) + return + } + + if err := r.ParseForm(); err != nil { + h.H.WriteError(ctx, w, r, err) + return + } + + auth, err := h.Warden.IntrospectToken(ctx, r.PostForm.Get("token")) + if err != nil { + h.H.Write(ctx, w, r, &inactive) + return + } else if clientCtx.Subject != auth.Audience { + h.H.Write(ctx, w, r, &inactive) + return + } + + h.H.Write(ctx, w, r, auth) } -func (h *WardenHandler) Authorized(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *WardenHandler) TokenValid(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { ctx := herodot.NewContext() - clientCtx, err := h.authorizeClient(ctx, w, r) + clientCtx, err := h.Warden.TokenAllowed(ctx, h.Warden.TokenFromRequest(r), &ladon.Request{ + Resource: "rn:hydra:warden:token:valid", + Action: "decide", + }, "hydra.warden") if err != nil { h.H.WriteError(ctx, w, r, err) return @@ -74,48 +109,84 @@ func (h *WardenHandler) Authorized(w http.ResponseWriter, r *http.Request, _ htt } defer r.Body.Close() - authContext, err := h.Warden.Authorized(ctx, ar.Assertion, ar.Scopes...) + authContext, err := h.Warden.InspectToken(ctx, ar.Token, ar.Scopes...) if err != nil { - h.H.WriteError(ctx, w, r, err) + h.H.Write(ctx, w, r, &invalid) return } authContext.Audience = clientCtx.Subject - h.H.Write(ctx, w, r, authContext) - + h.H.Write(ctx, w, r, struct { + *firewall.Context + Valid bool `json:"valid"` + }{ + Context: authContext, + Valid: true, + }) } func (h *WardenHandler) Allowed(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - ctx := herodot.NewContext() - clientCtx, err := h.authorizeClient(ctx, w, r) - if err != nil { + var ctx = herodot.NewContext() + if _, err := h.Warden.TokenAllowed(ctx, h.Warden.TokenFromRequest(r), &ladon.Request{ + Resource: "rn:hydra:warden:allowed", + Action: "decide", + }, "hydra.warden"); err != nil { h.H.WriteError(ctx, w, r, err) return } - var ar WardenAccessRequest - if err := json.NewDecoder(r.Body).Decode(&ar); err != nil { + var access = new(ladon.Request) + if err := json.NewDecoder(r.Body).Decode(access); err != nil { h.H.WriteError(ctx, w, r, errors.New(err)) return } + defer r.Body.Close() + + if err := h.Warden.IsAllowed(ctx, access); err != nil { + h.H.Write(ctx, w, r, ¬Allowed) + return + } + + res := notAllowed + res.Allowed = true + h.H.Write(ctx, w, r, &res) +} - authContext, err := h.Warden.ActionAllowed(ctx, ar.Assertion, ar.Request, ar.Scopes...) +func (h *WardenHandler) TokenAllowed(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + ctx := herodot.NewContext() + clientCtx, err := h.Warden.TokenAllowed(ctx, h.Warden.TokenFromRequest(r), &ladon.Request{ + Resource: "rn:hydra:warden:token:allowed", + Action: "decide", + }, "warden.token") if err != nil { h.H.WriteError(ctx, w, r, err) return } - authContext.Audience = clientCtx.Subject - h.H.Write(ctx, w, r, authContext) -} + var ar = WardenAccessRequest{ + Request: new(ladon.Request), + WardenAuthorizedRequest: new(WardenAuthorizedRequest), + } + if err := json.NewDecoder(r.Body).Decode(&ar); err != nil { + h.H.WriteError(ctx, w, r, errors.New(err)) + return + } + defer r.Body.Close() -func (h *WardenHandler) authorizeClient(ctx context.Context, w http.ResponseWriter, r *http.Request) (*firewall.Context, error) { - authctx, err := h.Warden.Authorized(ctx, TokenFromRequest(r), "core") + authContext, err := h.Warden.TokenAllowed(ctx, ar.Token, ar.Request, ar.Scopes...) if err != nil { - return nil, err + h.H.Write(ctx, w, r, ¬Allowed) + return } - return authctx, nil + authContext.Audience = clientCtx.Subject + h.H.Write(ctx, w, r, struct { + *firewall.Context + Allowed bool `json:"allowed"` + }{ + Context: authContext, + Allowed: true, + }) } func TokenFromRequest(r *http.Request) string { diff --git a/warden/warden_http.go b/warden/warden_http.go index 9632c25315c..482d563fe28 100644 --- a/warden/warden_http.go +++ b/warden/warden_http.go @@ -1,100 +1,133 @@ package warden import ( - "bytes" - "encoding/json" - "io/ioutil" "net/http" "net/url" "github.com/go-errors/errors" - . "github.com/ory-am/hydra/firewall" + "github.com/ory-am/fosite" + "github.com/ory-am/hydra/firewall" "github.com/ory-am/hydra/pkg" "github.com/ory-am/ladon" "golang.org/x/net/context" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" + "bytes" + "io/ioutil" + "strconv" + "encoding/json" ) type HTTPWarden struct { - Client *http.Client - + Client *http.Client + Dry bool Endpoint *url.URL } +func (w *HTTPWarden) TokenFromRequest(r *http.Request) string { + return fosite.AccessTokenFromRequest(r) +} + func (w *HTTPWarden) SetClient(c *clientcredentials.Config) { w.Client = c.Client(oauth2.NoContext) } -func (w *HTTPWarden) ActionAllowed(ctx context.Context, token string, a *ladon.Request, scopes ...string) (*Context, error) { - return w.doRequest(AllowedHandlerPath, &WardenAccessRequest{ - Request: a, - WardenAuthorizedRequest: &WardenAuthorizedRequest{ - Assertion: token, - Scopes: scopes, - }, - }) -} +func (w *HTTPWarden) IntrospectToken(ctx context.Context, token string) (*firewall.Introspection, error) { + var resp = new(firewall.Introspection) + var ep = *w.Endpoint + ep.Path = IntrospectPath + agent := &pkg.SuperAgent{URL: ep.String(), Client: w.Client} -func (w *HTTPWarden) HTTPActionAllowed(ctx context.Context, r *http.Request, a *ladon.Request, scopes ...string) (*Context, error) { - token := TokenFromRequest(r) - if token == "" { - return nil, errors.New(pkg.ErrUnauthorized) + data := url.Values{"token": []string{token}} + hreq, err := http.NewRequest("POST", ep.String(), bytes.NewBufferString(data.Encode())) + if err != nil { + return nil, errors.New(err) } - return w.ActionAllowed(ctx, token, a, scopes...) -} + hreq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + hreq.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) + hres, err := w.Client.Do(hreq) + if err != nil { + return nil, errors.New(err) + } -func (w *HTTPWarden) Authorized(ctx context.Context, token string, scopes ...string) (*Context, error) { - return w.doRequest(AuthorizedHandlerPath, &WardenAuthorizedRequest{ - Assertion: token, - Scopes: scopes, - }) -} + if hres.StatusCode < 200 || hres.StatusCode >= 300 { + body, _ := ioutil.ReadAll(hres.Body) + return nil, errors.Errorf("Expected 2xx status code but got %d.\n%s", hres.StatusCode, body) + } else if err := json.NewDecoder(hres.Body).Decode(resp); err != nil { + body, _ := ioutil.ReadAll(hres.Body) + return nil, errors.Errorf("%s: %s", err, body) + } -func (w *HTTPWarden) HTTPAuthorized(ctx context.Context, r *http.Request, scopes ...string) (*Context, error) { - token := TokenFromRequest(r) - if token == "" { - return nil, errors.New(pkg.ErrUnauthorized) + if err := agent.POST(&struct { + Token string `json:"token"` + }{Token: token}, &hres); err != nil { + return nil, err + } else if !resp.Active { + return nil, errors.New("Token is malformed, expired or otherwise invalid") } - return w.Authorized(ctx, token, scopes...) + return resp, nil } -func (w *HTTPWarden) doRequest(path string, request interface{}) (*Context, error) { - out, err := json.Marshal(request) - if err != nil { - return nil, errors.New(err) - } - - var ep = new(url.URL) - *ep = *w.Endpoint - ep.Path = path - req, err := http.NewRequest("POST", ep.String(), bytes.NewBuffer(out)) - if err != nil { - return nil, errors.New(err) - } +func (w *HTTPWarden) TokenAllowed(ctx context.Context, token string, a *ladon.Request, scopes ...string) (*firewall.Context, error) { + var resp = struct { + *firewall.Context + Allowed bool `json:"allowed"` + }{} - req.Header.Set("Content-Type", "application/json") - resp, err := w.Client.Do(req) - if err != nil { - return nil, errors.New(err) + var ep = *w.Endpoint + ep.Path = TokenAllowedHandlerPath + agent := &pkg.SuperAgent{URL: ep.String(), Client: w.Client} + if err := agent.POST(&WardenAccessRequest{ + WardenAuthorizedRequest: &WardenAuthorizedRequest{ + Token: token, + Scopes: scopes, + }, + Request: a, + }, &resp); err != nil { + return nil, err + } else if !resp.Allowed { + return nil, errors.New("Token is not valid") } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - all, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, errors.New(err) - } + return resp.Context, nil +} - return nil, errors.Errorf("Got error (%d): %s", resp.StatusCode, all) +func (w *HTTPWarden) IsAllowed(ctx context.Context, a *ladon.Request) error { + var allowed = struct { + Allowed bool `json:"allowed"` + }{} + + var ep = *w.Endpoint + ep.Path = AllowedHandlerPath + agent := &pkg.SuperAgent{URL: ep.String(), Client: w.Client} + if err := agent.POST(a, &allowed); err != nil { + return err + } else if !allowed.Allowed { + return errors.New("Forbidden") } - var epResp WardenResponse - if err := json.NewDecoder(resp.Body).Decode(&epResp); err != nil { - return nil, errors.New(err) + return nil +} + +func (w *HTTPWarden) InspectToken(ctx context.Context, token string, scopes ...string) (*firewall.Context, error) { + var resp = struct { + *firewall.Context + Valid bool `json:"valid"` + }{} + + var ep = *w.Endpoint + ep.Path = TokenValidHandlerPath + agent := &pkg.SuperAgent{URL: ep.String(), Client: w.Client} + if err := agent.POST(&WardenAuthorizedRequest{ + Token: token, + Scopes: scopes, + }, &resp); err != nil { + return nil, err + } else if !resp.Valid { + return nil, errors.New("Token is not valid") } - return epResp.Context, nil + return resp.Context, nil } diff --git a/warden/warden_local.go b/warden/warden_local.go index 9be52d972cd..b0c1bc28639 100644 --- a/warden/warden_local.go +++ b/warden/warden_local.go @@ -5,208 +5,143 @@ import ( "time" + "strings" + "github.com/Sirupsen/logrus" "github.com/go-errors/errors" "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/core" - . "github.com/ory-am/hydra/firewall" - "github.com/ory-am/hydra/herodot" + "github.com/ory-am/hydra/firewall" "github.com/ory-am/hydra/oauth2" - "github.com/ory-am/hydra/pkg" "github.com/ory-am/ladon" "golang.org/x/net/context" ) type LocalWarden struct { - Warden ladon.Warden - TokenValidator *core.CoreValidator + Warden ladon.Warden + OAuth2 fosite.OAuth2Provider AccessTokenLifespan time.Duration Issuer string } -func (w *LocalWarden) actionAllowed(ctx context.Context, a *ladon.Request, scopes []string, oauthRequest fosite.AccessRequester, session *oauth2.Session) (*Context, error) { - session = oauthRequest.GetSession().(*oauth2.Session) - if a.Subject != "" && a.Subject != session.Subject { - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": a.Subject, - "audience": oauthRequest.GetClient().GetID(), - "request": a, - "reason": "subject mismatch", - }).Infof("Access denied") - return nil, errors.New("Subject mismatch " + a.Subject + " - " + session.Subject) - } - - if !matchScopes(oauthRequest.GetGrantedScopes(), scopes, session, oauthRequest.GetClient()) { - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": a.Subject, - "audience": oauthRequest.GetClient().GetID(), - "request": a, - "reason": "scope mismatch", - }).Infof("Access denied") - return nil, errors.New(herodot.ErrForbidden) - } +func (w *LocalWarden) TokenFromRequest(r *http.Request) string { + return fosite.AccessTokenFromRequest(r) +} - a.Subject = session.Subject - if err := w.Warden.IsAllowed(a); err != nil { - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": a.Subject, - "audience": oauthRequest.GetClient().GetID(), - "request": a, - "reason": "policy effect is deny", - }).Infof("Access denied") - return nil, err +func (w *LocalWarden) IntrospectToken(ctx context.Context, token string) (*firewall.Introspection, error) { + var session = new(oauth2.Session) + var auth, err = w.OAuth2.ValidateToken(ctx, token, fosite.AccessToken, session) + if err != nil { + logrus.WithError(err).Infof("Token introspection failed") + return &firewall.Introspection{ + Active: false, + }, err } - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": a.Subject, - "audience": oauthRequest.GetClient().GetID(), - "request": a, - }).Infof("Access granted") - - return &Context{ - Subject: session.Subject, - GrantedScopes: oauthRequest.GetGrantedScopes(), - Issuer: w.Issuer, - Audience: oauthRequest.GetClient().GetID(), - IssuedAt: oauthRequest.GetRequestedAt(), - ExpiresAt: session.AccessTokenExpiresAt(oauthRequest.GetRequestedAt().Add(w.AccessTokenLifespan)), + session = auth.GetSession().(*oauth2.Session) + return &firewall.Introspection{ + Active: true, + Subject: session.Subject, + Audience: auth.GetClient().GetID(), + Scope: strings.Join(auth.GetGrantedScopes(), " "), + Issuer: w.Issuer, + IssuedAt: auth.GetRequestedAt().Unix(), + NotBefore: auth.GetRequestedAt().Unix(), + ExpiresAt: session.AccessTokenExpiresAt(auth.GetRequestedAt().Add(w.AccessTokenLifespan)).Unix(), }, nil } -func (w *LocalWarden) ActionAllowed(ctx context.Context, token string, a *ladon.Request, scopes ...string) (*Context, error) { - var session = new(oauth2.Session) - var oauthRequest = fosite.NewAccessRequest(session) - if err := w.TokenValidator.ValidateToken(ctx, oauthRequest, token); err != nil { - return nil, err +func (w *LocalWarden) IsAllowed(ctx context.Context, a *ladon.Request) error { + if err := w.Warden.IsAllowed(a); err != nil { + logrus.WithFields(logrus.Fields{ + "subject": a.Subject, + "request": a, + "reason": "request denied by policies", + }).WithError(err).Infof("Access denied") + return err } - return w.actionAllowed(ctx, a, scopes, oauthRequest, session) + return nil } -func (w *LocalWarden) HTTPActionAllowed(ctx context.Context, r *http.Request, a *ladon.Request, scopes ...string) (*Context, error) { +func (w *LocalWarden) TokenAllowed(ctx context.Context, token string, a *ladon.Request, scopes ...string) (*firewall.Context, error) { var session = new(oauth2.Session) - var oauthRequest = fosite.NewAccessRequest(session) - - if err := w.TokenValidator.ValidateRequest(ctx, r, oauthRequest); errors.Is(err, fosite.ErrUnknownRequest) { - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": a.Subject, - "audience": oauthRequest.GetClient().GetID(), - "request": a, - "reason": "unknown request", - }).Infof("Access denied") - return nil, errors.New(pkg.ErrUnauthorized) - } else if err != nil { + var auth, err = w.OAuth2.ValidateToken(ctx, token, fosite.AccessToken, session, scopes...) + if err != nil { logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": a.Subject, - "audience": oauthRequest.GetClient().GetID(), - "request": a, - "reason": "token validation failed", - }).Infof("Access denied") + "subject": a.Subject, + "request": a, + "reason": "token could not be validated", + }).WithError(err).Infof("Access denied") return nil, err } - return w.actionAllowed(ctx, a, scopes, oauthRequest, session) + return w.allowed(ctx, a, scopes, auth, session) } -func (w *LocalWarden) Authorized(ctx context.Context, token string, scopes ...string) (*Context, error) { +func (w *LocalWarden) InspectToken(ctx context.Context, token string, scopes ...string) (*firewall.Context, error) { var session = new(oauth2.Session) var oauthRequest = fosite.NewAccessRequest(session) - if err := w.TokenValidator.ValidateToken(ctx, oauthRequest, token); err != nil { + var auth, err = w.OAuth2.ValidateToken(ctx, token, fosite.AccessToken, session, scopes...) + if err != nil { logrus.WithFields(logrus.Fields{ "scopes": scopes, "subject": session.Subject, "audience": oauthRequest.GetClient().GetID(), "reason": "token validation failed", - }).Infof("Access denied") + }).WithError(err).Infof("Access denied") return nil, err } - session = oauthRequest.GetSession().(*oauth2.Session) - if !matchScopes(oauthRequest.GetGrantedScopes(), scopes, session, oauthRequest.Client) { - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": session, - "audience": oauthRequest.GetClient().GetID(), - "reason": "scope mismatch", - }).Infof("Access denied") - return nil, errors.New(herodot.ErrForbidden) - } - - return &Context{ - Subject: session.Subject, - GrantedScopes: oauthRequest.GetGrantedScopes(), - Issuer: w.Issuer, - Audience: oauthRequest.GetClient().GetID(), - IssuedAt: oauthRequest.GetRequestedAt(), - ExpiresAt: session.AccessTokenExpiresAt(oauthRequest.GetRequestedAt().Add(w.AccessTokenLifespan)), - }, nil + return w.newContext(auth), nil } -func (w *LocalWarden) HTTPAuthorized(ctx context.Context, r *http.Request, scopes ...string) (*Context, error) { - var session = new(oauth2.Session) - var oauthRequest = fosite.NewAccessRequest(session) - - if err := w.TokenValidator.ValidateRequest(ctx, r, oauthRequest); errors.Is(err, fosite.ErrUnknownRequest) { - logrus.WithFields(logrus.Fields{ - "scopes": scopes, - "subject": session.Subject, - "audience": oauthRequest.GetClient().GetID(), - "reason": "unknown request", - }).Infof("Access denied") - return nil, errors.New(pkg.ErrUnauthorized) - } else if err != nil { +func (w *LocalWarden) allowed(ctx context.Context, a *ladon.Request, scopes []string, oauthRequest fosite.AccessRequester, session *oauth2.Session) (*firewall.Context, error) { + session = oauthRequest.GetSession().(*oauth2.Session) + if a.Subject != "" && a.Subject != session.Subject { + err := errors.Errorf("Expected subject to be %s but got %s", session.Subject, a.Subject) logrus.WithFields(logrus.Fields{ "scopes": scopes, - "subject": session.Subject, + "subject": a.Subject, "audience": oauthRequest.GetClient().GetID(), - "reason": "token validation failed", - }).Infof("Access denied") + "request": a, + "reason": "subject mismatch", + }).WithError(err).Infof("Access denied") return nil, err } - session = oauthRequest.GetSession().(*oauth2.Session) - if !matchScopes(oauthRequest.GetGrantedScopes(), scopes, session, oauthRequest.Client) { + a.Subject = session.Subject + if err := w.Warden.IsAllowed(a); err != nil { logrus.WithFields(logrus.Fields{ "scopes": scopes, - "subject": session.Subject, + "subject": a.Subject, "audience": oauthRequest.GetClient().GetID(), - "reason": "scope mismatch", - }).Infof("Access denied") - return nil, errors.New(herodot.ErrForbidden) + "request": a, + "reason": "policy effect is deny", + }).WithError(err).Infof("Access denied") + return nil, err } - return &Context{ + return w.newContext(oauthRequest), nil +} + +func (w *LocalWarden) newContext(oauthRequest fosite.AccessRequester) *firewall.Context { + session := oauthRequest.GetSession().(*oauth2.Session) + c := &firewall.Context{ Subject: session.Subject, GrantedScopes: oauthRequest.GetGrantedScopes(), Issuer: w.Issuer, Audience: oauthRequest.GetClient().GetID(), IssuedAt: oauthRequest.GetRequestedAt(), ExpiresAt: session.AccessTokenExpiresAt(oauthRequest.GetRequestedAt().Add(w.AccessTokenLifespan)), - }, nil -} - -func matchScopes(granted []string, requested []string, session *oauth2.Session, c fosite.Client) bool { - scopes := &fosite.DefaultScopes{Scopes: granted} - for _, r := range requested { - if !scopes.Grant(r) { - logrus.WithFields(logrus.Fields{ - "reason": "scope mismatch", - "granted_scopes": granted, - "requested_scopes": requested, - "audience": c.GetID(), - "subject": session.Subject, - }).Infof("Authentication failed.") - return false - } + Extra: session.Extra, } - return true + logrus.WithFields(logrus.Fields{ + "subject": c.Subject, + "audience": oauthRequest.GetClient().GetID(), + }).Infof("Access granted") + + return c } diff --git a/warden/warden_test.go b/warden/warden_test.go index e0e5b54f044..60ef22957ae 100644 --- a/warden/warden_test.go +++ b/warden/warden_test.go @@ -2,7 +2,6 @@ package warden_test import ( "log" - "net/http" "net/http/httptest" "net/url" "testing" @@ -10,7 +9,7 @@ import ( "github.com/julienschmidt/httprouter" "github.com/ory-am/fosite" - "github.com/ory-am/fosite/handler/core" + foauth2 "github.com/ory-am/fosite/handler/oauth2" "github.com/ory-am/hydra/firewall" "github.com/ory-am/hydra/herodot" "github.com/ory-am/hydra/oauth2" @@ -30,15 +29,15 @@ var ladonWarden = pkg.LadonWarden(map[string]ladon.Policy{ "1": &ladon.DefaultPolicy{ ID: "1", Subjects: []string{"alice"}, - Resources: []string{"matrix"}, - Actions: []string{"create"}, + Resources: []string{"matrix", "rn:hydra:token<.*>"}, + Actions: []string{"create", "decide"}, Effect: ladon.AllowAccess, }, "2": &ladon.DefaultPolicy{ ID: "2", Subjects: []string{"siri"}, Resources: []string{"<.*>"}, - Actions: []string{}, + Actions: []string{"decide"}, Effect: ladon.AllowAccess, }, }) @@ -52,9 +51,16 @@ var tokens = pkg.Tokens(3) func init() { wardens["local"] = &warden.LocalWarden{ Warden: ladonWarden, - TokenValidator: &core.CoreValidator{ - AccessTokenStrategy: pkg.HMACStrategy, - AccessTokenStorage: fositeStore, + OAuth2: &fosite.Fosite{ + Store: fositeStore, + TokenValidators: fosite.TokenValidators{ + &foauth2.CoreValidator{ + CoreStrategy: pkg.HMACStrategy, + CoreStorage: fositeStore, + ScopeStrategy: fosite.HierarchicScopeStrategy, + }, + }, + ScopeStrategy: fosite.HierarchicScopeStrategy, }, Issuer: "tests", AccessTokenLifespan: time.Hour, @@ -62,14 +68,13 @@ func init() { r := httprouter.New() serv := &warden.WardenHandler{ - Ladon: ladonWarden, H: &herodot.JSON{}, Warden: wardens["local"], } serv.SetRoutes(r) ts = httptest.NewServer(r) - url, err := url.Parse(ts.URL + warden.AllowedHandlerPath) + url, err := url.Parse(ts.URL + warden.TokenAllowedHandlerPath) if err != nil { log.Fatalf("%s", err) } @@ -77,16 +82,19 @@ func init() { ar := fosite.NewAccessRequest(oauth2.NewSession("alice")) ar.GrantedScopes = fosite.Arguments{"core"} ar.RequestedAt = now + ar.Client = &fosite.DefaultClient{ID: "siri"} fositeStore.CreateAccessTokenSession(nil, tokens[0][0], ar) ar2 := fosite.NewAccessRequest(oauth2.NewSession("siri")) ar2.GrantedScopes = fosite.Arguments{"core"} ar2.RequestedAt = now + ar2.Client = &fosite.DefaultClient{ID: "siri"} fositeStore.CreateAccessTokenSession(nil, tokens[1][0], ar2) ar3 := fosite.NewAccessRequest(oauth2.NewSession("siri")) ar3.GrantedScopes = fosite.Arguments{"core"} ar3.RequestedAt = now + ar3.Client = &fosite.DefaultClient{ID: "doesnt-exist"} ar3.Session.(*oauth2.Session).AccessTokenExpiry = time.Now().Add(-time.Hour) fositeStore.CreateAccessTokenSession(nil, tokens[2][0], ar3) @@ -165,6 +173,17 @@ func TestActionAllowed(t *testing.T) { scopes: []string{"core"}, expectErr: true, }, + { + token: tokens[0][1], + req: &ladon.Request{ + Subject: "alice", + Resource: "matrix", + Action: "create", + Context: ladon.Context{}, + }, + scopes: []string{"illegal"}, + expectErr: true, + }, { token: tokens[0][1], req: &ladon.Request{ @@ -183,16 +202,43 @@ func TestActionAllowed(t *testing.T) { }, }, } { - ctx, err := w.ActionAllowed(context.Background(), c.token, c.req, c.scopes...) + ctx, err := w.TokenAllowed(context.Background(), c.token, c.req, c.scopes...) pkg.AssertError(t, c.expectErr, err, "ActionAllowed case", n, k) if err == nil && c.assert != nil { c.assert(ctx) } + } + } +} - httpreq := &http.Request{Header: http.Header{}} - httpreq.Header.Set("Authorization", "bearer "+c.token) - ctx, err = w.HTTPActionAllowed(context.Background(), httpreq, c.req, c.scopes...) - pkg.AssertError(t, c.expectErr, err, "HTTPAuthorized case", n, k) +func TestIntrospect(t *testing.T) { + for n, w := range wardens { + for k, c := range []struct { + token string + expectErr bool + assert func(*firewall.Introspection) + }{ + { + token: "invalid", + expectErr: true, + }, + { + token: tokens[2][1], + expectErr: true, + }, + { + token: tokens[0][1], + expectErr: false, + assert: func(c *firewall.Introspection) { + assert.Equal(t, "alice", c.Subject) + assert.Equal(t, "tests", c.Issuer) + assert.Equal(t, now.Add(time.Hour).Unix(), c.ExpiresAt) + assert.Equal(t, now.Unix(), c.IssuedAt) + }, + }, + } { + ctx, err := w.IntrospectToken(context.Background(), c.token) + pkg.AssertError(t, c.expectErr, err, "TestIntrospect case", n, k) if err == nil && c.assert != nil { c.assert(ctx) } @@ -200,7 +246,51 @@ func TestActionAllowed(t *testing.T) { } } -func TestAuthorized(t *testing.T) { +func TestAllowed(t *testing.T) { + for n, w := range wardens { + for k, c := range []struct { + req *ladon.Request + expectErr bool + assert func(*firewall.Context) + }{ + { + req: &ladon.Request{ + Subject: "alice", + Resource: "other-thing", + Action: "create", + Context: ladon.Context{}, + }, + expectErr: true, + }, + { + req: &ladon.Request{ + Subject: "alice", + Resource: "matrix", + Action: "delete", + Context: ladon.Context{}, + }, + expectErr: true, + }, + { + req: &ladon.Request{ + Subject: "alice", + Resource: "matrix", + Action: "create", + Context: ladon.Context{}, + }, + expectErr: false, + }, + } { + err := w.IsAllowed(context.Background(), c.req) + pkg.AssertError(t, c.expectErr, err, "TestAllowed case", n, k) + t.Logf("Passed test case %d\n", k) + } + t.Logf("Passed tests %s\n", n) + } + +} + +func TestTokenValid(t *testing.T) { for n, w := range wardens { for k, c := range []struct { token string @@ -221,6 +311,11 @@ func TestAuthorized(t *testing.T) { scopes: []string{"foo"}, expectErr: true, }, + { + token: tokens[1][1], + scopes: []string{"illegal"}, + expectErr: true, + }, { token: tokens[1][1], scopes: []string{"core"}, @@ -238,19 +333,11 @@ func TestAuthorized(t *testing.T) { expectErr: true, }, } { - ctx, err := w.Authorized(context.Background(), c.token, c.scopes...) + ctx, err := w.InspectToken(context.Background(), c.token, c.scopes...) pkg.AssertError(t, c.expectErr, err, "ActionAllowed case", n, k) if err == nil && c.assert != nil { c.assert(ctx) } - - httpreq := &http.Request{Header: http.Header{}} - httpreq.Header.Set("Authorization", "bearer "+c.token) - ctx, err = w.HTTPAuthorized(context.Background(), httpreq, c.scopes...) - pkg.AssertError(t, c.expectErr, err, "HTTPAuthorized case", n, k) - if err == nil && c.assert != nil { - c.assert(ctx) - } } } }