Finish auth example docs
This commit is contained in:
+228
-19
@@ -9,19 +9,21 @@ Future examples such as the multi-tenant blogging platform will use
|
|||||||
the results from this example for their auth. We will build a system
|
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
|
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
|
to manange other people's accounts. We will also see how to trigger
|
||||||
outside events like sending signup validation emails.
|
outside events like sending password reset emails.
|
||||||
|
|
||||||
Before jumping into the code, a little more about how the tokens
|
Before jumping into the code, a little more about how the tokens
|
||||||
work. Every JWT contains cryptographically signed *claims*. PostgREST
|
work. Every JWT contains cryptographically signed *claims*. PostgREST
|
||||||
cares specificaly about a claim called `role`. When a client includes
|
cares specificaly about a claim called `role`. When a client includes
|
||||||
a `role` claim PG executes their request using that database role.
|
a `role` claim PostgREST executes their request using that database
|
||||||
|
role.
|
||||||
|
|
||||||
How would a client include that claim? Without knowing the server
|
How would a client include a role claim, or claims in general?
|
||||||
JWT secret a client cannot create a claim. The only place to get a
|
Without knowing the server JWT secret a client cannot create a
|
||||||
JWT is from the PG server or from another service sharing the secret
|
claim. The only place to get a JWT is from the PostgREST server or
|
||||||
and acting on its behalf. We'll use a stored procedure returning
|
from another service sharing the secret and acting on its behalf.
|
||||||
type `jwt_claims` which is a special type and causes the server to
|
We'll use a stored procedure returning type `jwt_claims` which is
|
||||||
encrypt and sign the return value.
|
a special type causing the server to encrypt and sign the return
|
||||||
|
value.
|
||||||
|
|
||||||
### Storing Users and Passwords
|
### Storing Users and Passwords
|
||||||
|
|
||||||
@@ -41,8 +43,8 @@ create schema if not exists basic_auth;
|
|||||||
```
|
```
|
||||||
|
|
||||||
Next a table to store the mapping from usernames and passwords to
|
Next a table to store the mapping from usernames and passwords to
|
||||||
database roles. Includes triggers and functions to encrypt the
|
database roles. The code below includes triggers and functions to
|
||||||
password and ensure the role exists.
|
encrypt the password and ensure the role exists.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
create table if not exists
|
create table if not exists
|
||||||
@@ -132,9 +134,9 @@ basic_auth.tokens (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
In the main schema (not `basic_auth`) we expose a password reset
|
In the main schema (as opposed to the `basic_auth` schema) we expose
|
||||||
request function for the front end to call. It takes the email
|
a password reset request function. HTTP clients will call it. The
|
||||||
address of the user.
|
function takes the email address of the user.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
create or replace function
|
create or replace function
|
||||||
@@ -164,9 +166,9 @@ $$;
|
|||||||
|
|
||||||
This function does not send any emails. It sends a postgres
|
This function does not send any emails. It sends a postgres
|
||||||
[NOTIFY](http://www.postgresql.org/docs/current/static/sql-notify.html)
|
[NOTIFY](http://www.postgresql.org/docs/current/static/sql-notify.html)
|
||||||
command to trigger external actions. The most robust way to process
|
command. External programs such as a mailer listen for this event
|
||||||
these signals is by pushing them onto work queues. Here are two
|
and do the work. The most robust way to process these signals is
|
||||||
programs to do that:
|
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
|
1. [aweber/pgsql-listen-exchange](https://github.com/aweber/pgsql-listen-exchange) for RabbitMQ
|
||||||
2. [SpiderOak/skeeter](https://github.com/SpiderOak/skeeter) for ZeroMQ
|
2. [SpiderOak/skeeter](https://github.com/SpiderOak/skeeter) for ZeroMQ
|
||||||
@@ -192,8 +194,9 @@ ps.addChannel('validate', console.log);
|
|||||||
// modify me to send emails
|
// modify me to send emails
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the user has a reset token they can call this function
|
Once the user has a reset token they can use it as an argument to
|
||||||
through the PostgREST RPC interface.
|
the password reset function, calling it through the PostgREST RPC
|
||||||
|
interface.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
create or replace function
|
create or replace function
|
||||||
@@ -240,8 +243,214 @@ $$;
|
|||||||
|
|
||||||
This is similar to password resets. Once again we generate a token.
|
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
|
It differs in that there is a trigger to send validations when a
|
||||||
new login is added to the user table.
|
new login is added to the users table.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
create or replace function
|
||||||
|
basic_auth.send_validation() returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
tok uuid;
|
||||||
|
begin
|
||||||
|
select uuid_generate_v4() 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();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Editing Own User
|
||||||
|
|
||||||
|
We'll construct a redacted view for users. It hides passwords and
|
||||||
|
shows only those users whose roles the currently logged in user has
|
||||||
|
db permission to access.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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('postgrest.claims.email')
|
||||||
|
-- is equal to email so that user can only see themselves
|
||||||
|
```
|
||||||
|
|
||||||
|
Using this view clients can see themeslves and any other users with
|
||||||
|
the right db roles. 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, but we'll need a helper function
|
||||||
|
to prevent an escalation of privileges.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
With the above function we can now make a safe trigger to allow
|
||||||
|
user updates.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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();
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally add a public function people can use to sign up. You can
|
||||||
|
hard code a default db role in it. It alters the underlying
|
||||||
|
`basic_auth.users` so you can set whatever role you want without
|
||||||
|
restriction.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create or replace function
|
||||||
|
signup(email text, pass text) returns void
|
||||||
|
as $$
|
||||||
|
insert into basic_auth.users (email, pass, role) values
|
||||||
|
(signup.email, signup.pass, 'hardcoded-role-here');
|
||||||
|
$$ language sql;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating JWT
|
||||||
|
|
||||||
|
As mentioned at the start, clients authenticate with JWT. PostgREST
|
||||||
|
has a special convention to allow your sql functions to return JWT.
|
||||||
|
Any function that returns a type whose name ends in `jwt_claims` will
|
||||||
|
have its return value encoded. For instance, let's make a login function
|
||||||
|
which consults our users table.
|
||||||
|
|
||||||
|
First create a return type:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
drop type if exists basic_auth.jwt_claims cascade;
|
||||||
|
create type basic_auth.jwt_claims AS (role text, email text);
|
||||||
|
```
|
||||||
|
|
||||||
|
And now the function:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create or replace function
|
||||||
|
login(email text, pass text) returns basic_auth.jwt_claims
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
_role name;
|
||||||
|
result basic_auth.jwt_claims;
|
||||||
|
begin
|
||||||
|
select basic_auth.user_role(email, pass) into _role;
|
||||||
|
if _role is null then
|
||||||
|
raise invalid_password using message = 'invalid user or password';
|
||||||
|
end if;
|
||||||
|
-- TODO; check verified flag if you care whether users
|
||||||
|
-- have validated their emails
|
||||||
|
select _role as role, login.email as email into result;
|
||||||
|
return result;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
```
|
||||||
|
|
||||||
|
An API request to login would look like this.
|
||||||
|
|
||||||
|
```HTTP
|
||||||
|
POST /rpc/login
|
||||||
|
|
||||||
|
{ "email": "foo@bar.com", "pass": "foobar" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImZvb0BiYXIuY29tIiwicm9sZSI6ImF1dGhvciJ9.KHwYdK9dAMAg-MGCQXuDiFuvbmW-y8FjfYIcMrETnto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Try decoding the token at [jwt.io](http://jwt.io/). (It was encoded
|
||||||
|
with a secret of `secret` which is the default.) To use this token
|
||||||
|
in a future API request include it in an `Authorization` request
|
||||||
|
header.
|
||||||
|
|
||||||
|
```HTTP
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImZvb0BiYXIuY29tIiwicm9sZSI6ImF1dGhvciJ9.KHwYdK9dAMAg-MGCQXuDiFuvbmW-y8FjfYIcMrETnto
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conclusion
|
||||||
|
|
||||||
|
This section explained the implementation details for building a
|
||||||
|
password based authentication system in pure sql. The next example
|
||||||
|
will put it to work in a multi-tenant blogging API.
|
||||||
|
|||||||
Reference in New Issue
Block a user