------------------------------------------------------------------------------- -- Adapted from https://github.com/robconery/pg-auth begin; -- comment out the role creation statements if -- you want to run this script more than once create role anon; create role author; create role authenticator noinherit; grant anon, author to authenticator; 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; ------------------------------------------------------------------------------- -- Utility functions 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; ------------------------------------------------------------------------------- -- Users storage and constraints 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(); 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(); ------------------------------------------------------------------------------- -- Email Validation and Password Reset 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 ); ------------------------------------------------------------------------------- -- Login helper 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; $$; create or replace function basic_auth.current_email() returns text language plpgsql as $$ begin return current_setting('postgrest.claims.email'); exception -- handle unrecognized configuration parameter error when undefined_object then return ''; end; $$; ------------------------------------------------------------------------------- -- Public functions (in current schema, not basic_auth) 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; $$; 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; $$; drop type if exists basic_auth.jwt_claims cascade; create type basic_auth.jwt_claims AS (role text, email text); 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; $$; 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, 'author'); $$ language sql; ------------------------------------------------------------------------------- -- User management 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 and ( actual.role <> 'author' or email = basic_auth.current_email() ); 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 (coalesce(new.role, 'author'), 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 even 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(); ------------------------------------------------------------------------------- -- Blogging stuff! create table if not exists posts ( id bigserial primary key, title text not null, body text not null, author text not null references basic_auth.users (email) on delete restrict on update cascade default basic_auth.current_email(), created_at timestamptz not null default current_date ); create table if not exists comments ( id bigserial primary key, body text not null, author text not null references basic_auth.users (email) on delete restrict on update cascade default basic_auth.current_email(), post bigint not null references posts (id) on delete cascade on update cascade, created_at timestamptz not null default current_date ); ------------------------------------------------------------------------------- -- Permissions grant insert on table basic_auth.users, basic_auth.tokens to anon; grant select on table pg_authid, basic_auth.users, posts, comments to anon; grant execute on function login(text,text), request_password_reset(text), reset_password(text,uuid,text), signup(text, text) to anon; grant select, insert, update, delete on basic_auth.tokens, basic_auth.users to anon, author; grant select, insert, update, delete on table users, posts, comments to author; grant usage, select on sequence posts_id_seq, comments_id_seq to author; grant usage on schema public, basic_auth to anon, author; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; drop policy if exists authors_eigenedit on posts; create policy authors_eigenedit on posts using (true) with check ( author = basic_auth.current_email() ); ALTER TABLE comments ENABLE ROW LEVEL SECURITY; drop policy if exists authors_eigenedit on comments; create policy authors_eigenedit on comments using (true) with check ( author = basic_auth.current_email() ); commit;