From 30a844ff58ed0d39d3b0c72d0962fa35d3f04a05 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Thu, 17 Dec 2015 00:15:21 -0500 Subject: [PATCH 1/4] Adds External Authentication in docs' examples section --- docs/examples/external_auth.md | 165 +++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 166 insertions(+) create mode 100644 docs/examples/external_auth.md diff --git a/docs/examples/external_auth.md b/docs/examples/external_auth.md new file mode 100644 index 000000000..0c7a8c202 --- /dev/null +++ b/docs/examples/external_auth.md @@ -0,0 +1,165 @@ +## External Authentication + +API clients authenticate with [JSON Web Tokens](http://jwt.io). +PostgREST does not support any other authentication mechanism +directly, but they can be built on top. In this demo we will build +a system that works with an external authentication server +and integrates with a PostgREST server by sharing the same JWT secret. + +For a better understanding of JWT and PostgREST authentication system you should read +the [User Management](users/) example as well. + +I'll use a [Rails](http://rubyonrails.org) application using [Devise](https://github.com/plataformatec/devise) +just to make the example more concrete, but this could be replicated for +any other external authentication system using the same principles. +In case Rails is not your cup of tea you can continue reading and +just skip the Ruby code samples. I'll also assume +the use of JQuery for some client-side code samples for the sake of simplicity. + +I won't delve into Devise authentication details, for this would require a tutorial on its own, +so I'm assuming that the reader's authentication system is already working. + +### Sharing the JWT Secret + +To allow a third party to generate valid JWTs for your PostgREST API +is just a matter of sharing a secret. So you need to give your authenticator +software the same secret that was used in your API server under the ```--jwt-secret``` +parameter. + +This could be done easly using environment variables. You set a ```JWT_SECRET``` variable +in the environment where you run your rails app and it will be accessible in the global +variable ```ENV['JWT_SECRET']```. + +### User Model + +We will map each user in this example to two database roles. +So our application users are either ```admin``` or ```customer```. +If they are just visitors (not logged in) to our website they will be ```anonymous```. +One way of mapping users is to add a field in our users table indicating their database role. +I'll add a text field called role to my users table: + +```sql +ALTER TABLE users ADD role text NOT NULL DEFAULT 'customer'; +``` + +For our example we will need besides the main user that PostgREST uses to connect to PostgreSQL +and the anonymous user, we will have two aditional roles: + +* admin - to be used by users where admin = true +* customer - to be used where admin = false + +Bellow we have the commands to create all roles that will be used: +```sql +CREATE USER postgrest; +CREATE ROLE anonymous; +CREATE ROLE admin; +CREATE ROLE customer; + +GRANT customer, admin, anonymous TO postgrest; +``` + +### Generating a JWT + +Several libraries are available to generate JWT, you will find a very handy list in [their website](http://jwt.io) +under **Libraries**. +To continue our Rails example I'll use the ruby library [json_web_token](https://github.com/garyf/json_web_token). + +In order to make the gem available in my Rails project I add the following line to my Gemfile: + +``` +gem 'json_web_token' +``` + +Then we create a Rails controller to serve JWTs for my authenticated users. +For this I just open a file ```app/controllers/api_tokens_controller.rb``` with the content: + +```ruby +class ApiTokensController < ApplicationController + def show + unless ENV['JWT_SECRET'].present? + return render json: {error: "you need to have JWT_SECRET configured to get an API token"}, status: 500 + end + + unless current_user.present? + return render json: {error: "only authenticated users can request the API token"}, status: 401 + end + + render json: {token: jwt}, status: 200 + end + + private + def jwt + JsonWebToken.sign(claims, key: ENV['JWT_SECRET']) + end + + def claims + # I'm assuming a boolean field admin in the user model indicating wheter the + # user has administrative privileges. + { role: current_user.role, user_id: current_user.id.to_s } + end +end +``` + +We also need to create a route in the ```config/routes.rb``` file: + +```ruby +resource :api_token, only: [:show] +``` + +Now, any authenticated user in our rails application can request an api_token making a GET +request to ```/api_token```. This endpoint will return a json object with one property +whose value is the token the API requests should use. + +### Orders Endpoint + +Here we describe how to create a view to generate an endpoint /orders filtered by +the logged in user. +```sql +ALTER DATABASE mydb SET postgrest.claims.user_id TO ''; + +CREATE OR REPLACE FUNCTION user_id() +RETURNS integer +STABLE +LANGUAGE SQL +AS $$ + SELECT nullif(current_setting('postgrest.claims.user_id'), '')::integer; +$$; + +CREATE OR REPLACE FUNCTION is_owner_or_admin(user_id int) +RETURNS boolean +STABLE +LANGUAGE SQL +AS $$ + SELECT current_user = 'admin' OR is_owner_or_admin.user_id = user_id(); +$$; + +CREATE SCHEMA private; + +CREATE TABLE private.orders ( + id serial primary key, + user_id int references users, + created_at timestamp not null default current_timestamp, + updated_at timestamp not null default current_timestamp +); + +CREATE VIEW orders AS +SELECT + id, user_id, created_at, updated_at +FROM + private.orders o +WHERE + is_owner_or_admin(o.user_id); +``` + +### Using the JWT + +Now any page generated by our Rails app, after we are authenticated we can use a simple +Javascript code to get our token and use it: +```javascript +$.getJSON('/api_json').done(function(data){ + $.ajax('/orders', {'Authorization': 'Bearer ' + data.token}) + }) + .fail(function(){ + console.log('Error fetching API token'); + }) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1957209d3..e012d5156 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,4 +23,5 @@ pages: - Examples: - Getting Started: examples/start.md - User Management: examples/users.md + - External Authentication: examples/external_auth.md - Multi-Tenant Blog: examples/blog.md From 1d848b8d72df7316a3711d9edb7bd8fa8ebbe3e4 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 20 Dec 2015 12:38:05 -0500 Subject: [PATCH 2/4] Removes is_owner_or_admin and adds token expiration claim --- docs/examples/external_auth.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/examples/external_auth.md b/docs/examples/external_auth.md index 0c7a8c202..4c560416e 100644 --- a/docs/examples/external_auth.md +++ b/docs/examples/external_auth.md @@ -50,7 +50,7 @@ and the anonymous user, we will have two aditional roles: Bellow we have the commands to create all roles that will be used: ```sql -CREATE USER postgrest; +CREATE USER authenticator NOINHERIT; CREATE ROLE anonymous; CREATE ROLE admin; CREATE ROLE customer; @@ -95,7 +95,8 @@ class ApiTokensController < ApplicationController def claims # I'm assuming a boolean field admin in the user model indicating wheter the # user has administrative privileges. - { role: current_user.role, user_id: current_user.id.to_s } + # This token will expire 1 hour after being issued + { role: current_user.role, user_id: current_user.id.to_s, exp: (Time.now + 1.hour).to_i } end end ``` @@ -117,7 +118,7 @@ the logged in user. ```sql ALTER DATABASE mydb SET postgrest.claims.user_id TO ''; -CREATE OR REPLACE FUNCTION user_id() +CREATE OR REPLACE FUNCTION current_user_id() RETURNS integer STABLE LANGUAGE SQL @@ -125,14 +126,6 @@ AS $$ SELECT nullif(current_setting('postgrest.claims.user_id'), '')::integer; $$; -CREATE OR REPLACE FUNCTION is_owner_or_admin(user_id int) -RETURNS boolean -STABLE -LANGUAGE SQL -AS $$ - SELECT current_user = 'admin' OR is_owner_or_admin.user_id = user_id(); -$$; - CREATE SCHEMA private; CREATE TABLE private.orders ( @@ -148,7 +141,7 @@ SELECT FROM private.orders o WHERE - is_owner_or_admin(o.user_id); + current_user = 'admin' OR o.user_id = current_user_id(); ``` ### Using the JWT From eef4e3c647c6ff85829157ae7e9df4625903d941 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 20 Dec 2015 14:45:22 -0500 Subject: [PATCH 3/4] Tweaks the text, adds a design remark about SQL functions and adds the token TTL in HTTP cache headers --- docs/examples/external_auth.md | 42 +++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/examples/external_auth.md b/docs/examples/external_auth.md index 4c560416e..e9a191ed5 100644 --- a/docs/examples/external_auth.md +++ b/docs/examples/external_auth.md @@ -21,7 +21,7 @@ so I'm assuming that the reader's authentication system is already working. ### Sharing the JWT Secret -To allow a third party to generate valid JWTs for your PostgREST API +Allowing a third party to generate valid JWTs for your PostgREST API is just a matter of sharing a secret. So you need to give your authenticator software the same secret that was used in your API server under the ```--jwt-secret``` parameter. @@ -42,11 +42,11 @@ I'll add a text field called role to my users table: ALTER TABLE users ADD role text NOT NULL DEFAULT 'customer'; ``` -For our example we will need besides the main user that PostgREST uses to connect to PostgreSQL -and the anonymous user, we will have two aditional roles: +Besides the main user that PostgREST uses to connect to PostgreSQL +and the anonymous user, we will need two aditional roles for our example: -* admin - to be used by users where admin = true -* customer - to be used where admin = false +* admin - to be used by users that access all the system rows. +* customer - to be used when user has restricted access to database rows. Bellow we have the commands to create all roles that will be used: ```sql @@ -55,7 +55,7 @@ CREATE ROLE anonymous; CREATE ROLE admin; CREATE ROLE customer; -GRANT customer, admin, anonymous TO postgrest; +GRANT customer, admin, anonymous TO authenticator; ``` ### Generating a JWT @@ -75,6 +75,8 @@ For this I just open a file ```app/controllers/api_tokens_controller.rb``` with ```ruby class ApiTokensController < ApplicationController + TOKEN_TTL = 1.hour + def show unless ENV['JWT_SECRET'].present? return render json: {error: "you need to have JWT_SECRET configured to get an API token"}, status: 500 @@ -84,6 +86,7 @@ class ApiTokensController < ApplicationController return render json: {error: "only authenticated users can request the API token"}, status: 401 end + expires_in TOKEN_TTL, public: false render json: {token: jwt}, status: 200 end @@ -93,10 +96,12 @@ class ApiTokensController < ApplicationController end def claims - # I'm assuming a boolean field admin in the user model indicating wheter the - # user has administrative privileges. # This token will expire 1 hour after being issued - { role: current_user.role, user_id: current_user.id.to_s, exp: (Time.now + 1.hour).to_i } + { + role: current_user.role, + user_id: current_user.id.to_s, + exp: (Time.now + TOKEN_TTL).to_i + } end end ``` @@ -113,8 +118,9 @@ whose value is the token the API requests should use. ### Orders Endpoint -Here we describe how to create a view to generate an endpoint /orders filtered by -the logged in user. +Here is how to create a view to generate an endpoint ```/orders``` filtered by +the logged in user: + ```sql ALTER DATABASE mydb SET postgrest.claims.user_id TO ''; @@ -144,10 +150,20 @@ WHERE current_user = 'admin' OR o.user_id = current_user_id(); ``` +
+

DRY priviledge checking conditions

+

+ You can encapsulate conditions that will be commonly used to check for privileges while reading a database row. + We used a function current_user_id() but we could add more conditions to functions + as the system becomes more complex.
+ Remeber to mark your functions as STABLE so that PostgreSQL can inline then while planning the query. +

+
+ ### Using the JWT -Now any page generated by our Rails app, after we are authenticated we can use a simple -Javascript code to get our token and use it: +Now whenever you are authenticated in your Rails application you can use some Javascript + code to get the token and use it: ```javascript $.getJSON('/api_json').done(function(data){ $.ajax('/orders', {'Authorization': 'Bearer ' + data.token}) From 6534eeb1a2017f16c62ed27bf5ff46d51666eff8 Mon Sep 17 00:00:00 2001 From: Diogo Biazus Date: Sun, 20 Dec 2015 15:04:18 -0500 Subject: [PATCH 4/4] Adds conclusion and note about token TTL --- docs/examples/external_auth.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/examples/external_auth.md b/docs/examples/external_auth.md index e9a191ed5..8df6d906f 100644 --- a/docs/examples/external_auth.md +++ b/docs/examples/external_auth.md @@ -106,6 +106,16 @@ class ApiTokensController < ApplicationController end ``` +
+

Token Time to Live

+

+ In the code above we leverage the HTTP time based cache headers to expire the + endpoint cache at the same time as the token. In this example we have a token + that will be refresh one hour after its issuing time. + That's why both are based on the TOKEN_TTL constant. +

+
+ We also need to create a route in the ```config/routes.rb``` file: ```ruby @@ -166,9 +176,18 @@ Now whenever you are authenticated in your Rails application you can use some Ja code to get the token and use it: ```javascript $.getJSON('/api_json').done(function(data){ - $.ajax('/orders', {'Authorization': 'Bearer ' + data.token}) - }) - .fail(function(){ - console.log('Error fetching API token'); + $.ajax('/orders', {'Authorization': 'Bearer ' + data.token}).done(function(data){ + console.log('Visible Orders: ', data); }) +}).fail(function(){ + console.log('Error fetching API token'); +}) ``` +We could also store the token to avoid having to fetch it again in the same page. + +### Conclusion + +This section explained the implementation details for building an +external authentication system working with PostgREST. +With the previous [User Management](users/) example this should give a clearer +idea of how to set up authentication for your API.