diff --git a/auth.rst b/auth.rst index cc1aecbbe..17ef0d913 100644 --- a/auth.rst +++ b/auth.rst @@ -231,8 +231,6 @@ First we'll need a table to keep track of our users: email text primary key check ( email ~* '^.+@.+\..+$' ), pass text not null check (length(pass) < 512), role name not null check (length(role) < 512), - verified boolean not null default false - -- If you like add more columns, or a json column ); We would like the role to be a foreign key to actual database roles, however PostgreSQL does not support these constraints against the `pg_roles` table. We'll use a trigger to manually enforce it. @@ -300,36 +298,13 @@ With the table in place we can make a helper to check a password against the enc end; $$; -Finally we want a helper function to check whether the database user for the current API request has access to see or change a given role. This will become useful in the next section. - -.. code:: postgres - - create or replace function - basic_auth.clearance_for_role(u name) returns void as - $$ - declare - ok boolean; - begin - select exists ( - select rolname - from pg_authid - where pg_has_role(current_user, oid, 'member') - and rolname = u - ) into ok; - if not ok then - raise invalid_password using message = - 'current user not member of role ' || u; - end if; - end - $$ LANGUAGE plpgsql; - Public User Interface --------------------- -In the previous section we created an internal place to store user information. Here we create views and functions in a public schema that clients will access through the HTTP API. These public relations allow users view or edit their own information, log in, sign up, etc. +In the previous section we created an internal table to store user information. Here we create a login function which takes an email address and password and returns JWT if the credentials match a user in the internal table. -Logins and Signup -~~~~~~~~~~~~~~~~~ +Logins +~~~~~~ As described in `JWT from SQL`_, we'll create a JWT inside our login function. Note that you'll need to adjust the secret key which is hardcoded in this example to a secure secret of your choosing. @@ -341,8 +316,6 @@ As described in `JWT from SQL`_, we'll create a JWT inside our login function. N as $$ declare _role name; - _verified boolean; - _email text; result basic_auth.jwt_claims; begin -- check email and password @@ -350,13 +323,6 @@ As described in `JWT from SQL`_, we'll create a JWT inside our login function. N if _role is null then raise invalid_password using message = 'invalid user or password'; end if; - -- check verified flag whether users - -- have validated their emails - _email := email; - select verified from basic_auth.users as u where u.email=_email limit 1 into _verified; - if not _verified then - raise invalid_authorization_specification using message = 'user is not verified'; - end if; select jwt.sign( row_to_json(r), 'mysecret' @@ -386,72 +352,6 @@ The response would look like the snippet below. Try decoding the token at `jwt.i "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImZvb0BiYXIuY29tIiwicm9sZSI6ImF1dGhvciJ9.fpf3_ERi5qbWOE5NPzvauJgvulm0zkIG9xSm2w5zmdw" } -Editing User Info -~~~~~~~~~~~~~~~~~ - -By creating a public wrapper around the internal users table we can allow people to safely edit it through the same auto-generated API that apply to other tables and views. The following view redacts sensitive information. It hides passwords and shows only those users whose roles the currently logged in user has database permission to access. - -.. code:: postgres - - create or replace view users as - select actual.role as role, - '***'::text as pass, - actual.email as email, - actual.verified as verified - from basic_auth.users as actual, - (select rolname - from pg_authid - where pg_has_role(current_user, oid, 'member') - ) as member_of - where actual.role = member_of.rolname; - -- can also add restriction that current_setting('request.jwt.claim.email') - -- is equal to email so that user can only see themselves - -Using this view a client can see their role and any other users to whose roles the client belongs. This view does not yet support inserts or updates because not all the columns refer directly to underlying columns. Nor do we want it to be auto-updatable because it would allow an escalation of privileges. Someone could update their own row and change their role to become more powerful. We'll handle updates with a trigger: - -.. code:: plpgsql - - create or replace function - update_users() returns trigger - language plpgsql - AS $$ - begin - if tg_op = 'INSERT' then - perform basic_auth.clearance_for_role(new.role); - - insert into basic_auth.users - (role, pass, email, verified) - values ( - new.role, new.pass, new.email, - coalesce(new.verified, false)); - return new; - elsif tg_op = 'UPDATE' then - -- no need to check clearance for old.role because - -- an ineligible row would not have been available to update (http 404) - perform basic_auth.clearance_for_role(new.role); - - update basic_auth.users set - email = new.email, - role = new.role, - pass = new.pass, - verified = coalesce(new.verified, old.verified, false) - where email = old.email; - return new; - elsif tg_op = 'DELETE' then - -- no need to check clearance for old.role (see previous case) - - delete from basic_auth.users - where basic_auth.email = old.email; - return null; - end if; - end - $$; - - drop trigger if exists update_users on users; - create trigger update_users - instead of insert or update or delete on - users for each row execute procedure update_users(); - Permissions ~~~~~~~~~~~ @@ -466,121 +366,7 @@ Your database roles need access to the schema, tables, views and functions in or grant anon to authenticator; grant usage on schema public, basic_auth to anon; - - -- anon can create new logins - grant insert on table basic_auth.users, basic_auth.tokens to anon; grant select on table pg_authid, basic_auth.users to anon; - grant execute on function - login(text,text), - signup(text, text) - to anon; + grant execute on function login(text,text) to anon; You may be worried from the above that anonymous users can read everything from the `basic_auth.users` table. However this table is not available for direct queries because it lives in a separate schema. The anonymous role needs access because the public `users` view reads the underlying table with the permissions of the calling user. But we have made sure the view properly restricts access to sensitive information. - -Interacting with Email ----------------------- - -External actions like sending an email or calling 3rd-party services are possible in PostgREST but must be handled with care. Even if there are PostgreSQL extensions to make network requests it is bad practice to do this in SQL. Blocking on the outside world is unhealthy in a database and holds open long-running transactions. The proper approach is for the database to signal an external program to perform the required action and then not block on the result. - -One way to do this is using a table to implement a job queue for external programs. However this approach is `dangerous `_ because of its potential interactions with unrelated long-running queries. However things are improving with PostgreSQL 9.5 which introduces SKIP LOCKED to build reliable work queues, see `this article `_. - -Another way to queue tasks for external processing is by bridging PostgreSQL's `LISTEN `_/`NOTIFY `_ pubsub with a dedicated external queue system. Two programs to listen for database events and queue them are - -* `aweber/pgsql-listen-exchange `_ for RabbitMQ -* `SpiderOak/skeeter `_ for ZeroMQ - -For experimentation purposes you can also have external programs LISTEN directly for PostgreSQL events. It's less robust than a queuing system but an example Node program might look like this: - -.. code:: js - - var PS = require('pg-pubsub'); - - if(process.argv.length !== 3) { - console.log("USAGE: DB_URL"); - process.exit(2); - } - var url = process.argv[2], - ps = new PS(url); - - // password reset request events - ps.addChannel('reset', console.log); - // email validation required event - ps.addChannel('validate', console.log); - - // modify me to send emails - -To use this LISTEN/NOTIFY approach (with or without a real queue hooked up) we can make our SQL functions issue a NOTIFY to perform external actions. Two such such functions are those to confirm an email address or send a password reset token. Both will use nonces and need a place to store them, so we'll start there. - -.. code:: postgres - - create type token_type_enum as enum ('validation', 'reset'); - - create table if not exists - basic_auth.tokens ( - token uuid primary key, - token_type token_type_enum not null, - email text not null references basic_auth.users (email) - on delete cascade on update cascade, - created_at timestamptz not null default current_date - ); - -Here is a password reset function to make public for API requests. The function takes a user email address. - -.. code:: plpgsql - - create or replace function - request_password_reset(email text) returns void - language plpgsql - as $$ - declare - tok uuid; - begin - delete from basic_auth.tokens - where token_type = 'reset' - and tokens.email = request_password_reset.email; - - select gen_random_uuid() into tok; - insert into basic_auth.tokens (token, token_type, email) - values (tok, 'reset', request_password_reset.email); - perform pg_notify('reset', - json_build_object( - 'email', request_password_reset.email, - 'token', tok, - 'token_type', 'reset' - )::text - ); - end; - $$; - -Notice the use of `pg_notify` above. It notifies a channel called `reset` with a JSON object containing details of the email address and token. A worker process would directly LISTEN for this event or would pull it off a queue and do the work to send an email with a friendly human readable message. - -Similar to the password reset request, an email validation function creates a token and then defers to external processing. This one won't be publicly accessible, but rather can be triggered on user account creation. - -.. code:: plpgsql - - create or replace function - basic_auth.send_validation() returns trigger - language plpgsql - as $$ - declare - tok uuid; - begin - select gen_random_uuid() into tok; - insert into basic_auth.tokens (token, token_type, email) - values (tok, 'validation', new.email); - perform pg_notify('validate', - json_build_object( - 'email', new.email, - 'token', tok, - 'token_type', 'validation' - )::text - ); - return new; - end - $$; - - drop trigger if exists send_validation on basic_auth.users; - create trigger send_validation - after insert on basic_auth.users - for each row - execute procedure basic_auth.send_validation();