WIP: documenting how to build username/pw auth on top of jwt

This commit is contained in:
Joe Nelson
2015-11-28 16:07:51 -08:00
parent 048a8531f2
commit fa281fe59c
2 changed files with 248 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
## User Management
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 username and password system on top of JWT using only plpgsql.
Future examples such as the multi-tenant blogging platform will use
the results from this example for their auth. We will build a system
for users to sign up, log in, manage their accounts, and for admins
to manange other people's accounts. We will also see how to trigger
outside events like sending signup validation emails.
Before jumping into the code, a little more about how the tokens
work. Every JWT contains cryptographically signed *claims*. PostgREST
cares specificaly about a claim called `role`. When a client includes
a `role` claim PG executes their request using that database role.
How would a client include that claim? Without knowing the server
JWT secret a client cannot create a claim. The only place to get a
JWT is from the PG server or from another service sharing the secret
and acting on its behalf. We'll use a stored procedure returning
type `jwt_claims` which is a special type and causes the server to
encrypt and sign the return value.
### Storing Users and Passwords
We create a database schema especially for auth information. We'll
also need the postgres extensions
[pgcrypto](http://www.postgresql.org/docs/current/static/pgcrypto.html) and
[uuid-ossp](http://www.postgresql.org/docs/current/static/uuid-ossp.html).
```sql
create extension if not exists pgcrypto;
create extension if not exists "uuid-ossp";
-- We put things inside the basic_auth schema to hide
-- them from public view. Certain public procs/views will
-- refer to helpers and tables inside.
create schema if not exists basic_auth;
```
Next a table to store the mapping from usernames and passwords to
database roles. Includes triggers and functions to encrypt the
password and ensure the role exists.
```sql
create table if not exists
basic_auth.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
);
create or replace function
basic_auth.check_role_exists() returns trigger
language plpgsql
as $$
begin
if not exists (select 1 from pg_roles as r where r.rolname = new.role) then
raise foreign_key_violation using message =
'unknown database role: ' || new.role;
return null;
end if;
return new;
end
$$;
drop trigger if exists ensure_user_role_exists on basic_auth.users;
create constraint trigger ensure_user_role_exists
after insert or update on basic_auth.users
for each row
execute procedure basic_auth.check_role_exists();
create or replace function
basic_auth.encrypt_pass() returns trigger
language plpgsql
as $$
begin
if tg_op = 'INSERT' or new.pass <> old.pass then
new.pass = crypt(new.pass, gen_salt('bf'));
end if;
return new;
end
$$;
drop trigger if exists encrypt_pass on basic_auth.users;
create trigger encrypt_pass
before insert or update on basic_auth.users
for each row
execute procedure basic_auth.encrypt_pass();
```
With the table in place we can make a helper to check passwords.
It returns the database role for a user if the email and password
are correct.
```sql
create or replace function
basic_auth.user_role(email text, pass text) returns name
language plpgsql
as $$
begin
return (
select role from basic_auth.users
where users.email = user_role.email
and users.pass = crypt(user_role.pass, users.pass)
);
end;
$$;
```
### Password Reset
When a user requests a password reset or signs up we create a token
they will use later to prove their identity. The tokens go in this
table.
```sql
drop type if exists token_type_enum cascade;
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
);
```
In the main schema (not `basic_auth`) we expose a password reset
request function for the front end to call. It takes the email
address of the user.
```sql
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 uuid_generate_v4() 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;
$$;
```
This function does not send any emails. It sends a postgres
[NOTIFY](http://www.postgresql.org/docs/current/static/sql-notify.html)
command to trigger external actions. The most robust way to process
these signals is by pushing them onto work queues. Here are two
programs to do that:
1. [aweber/pgsql-listen-exchange](https://github.com/aweber/pgsql-listen-exchange) for RabbitMQ
2. [SpiderOak/skeeter](https://github.com/SpiderOak/skeeter) for ZeroMQ
For experimentation you don't need that though. Here's a sample
Node program that listens for the events and logs them to stdout.
```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
```
Once the user has a reset token they can call this function
through the PostgREST RPC interface.
```sql
create or replace function
reset_password(email text, token uuid, pass text)
returns void
language plpgsql
as $$
declare
tok uuid;
begin
if exists(select 1 from basic_auth.tokens
where tokens.email = reset_password.email
and tokens.token = reset_password.token
and token_type = 'reset') then
update basic_auth.users set pass=reset_password.pass
where users.email = reset_password.email;
delete from basic_auth.tokens
where tokens.email = reset_password.email
and tokens.token = reset_password.token
and token_type = 'reset';
else
raise invalid_password using message =
'invalid user or token';
end if;
delete from basic_auth.tokens
where token_type = 'reset'
and tokens.email = reset_password.email;
select uuid_generate_v4() into tok;
insert into basic_auth.tokens (token, token_type, email)
values (tok, 'reset', reset_password.email);
perform pg_notify('reset',
json_build_object(
'email', reset_password.email,
'token', tok
)::text
);
end;
$$;
```
### Email Validation
This is similar to password resets. Once again we generate a token.
It differs in that there is a trigger to send validations when a
new login is added to the user table.
```sql
```
+1
View File
@@ -22,3 +22,4 @@ pages:
- Performance: admin/performance.md
- Examples:
- Getting Started: examples/start.md
- User Management: examples/users.md