Compare commits

..

5 Commits

Author SHA1 Message Date
jordi fita mas d9c93b8797 Add function to change the current user’s password
This function does not ask for the confirmation because this is an
user-facing issue, not for the database.

Still missing: validation and proper error messages.
2023-01-23 21:41:14 +01:00
jordi fita mas 56d149e211 Remove execution grant on build_cookie to guest
There is no need for a guest to build any cookie, since they have none.
2023-01-23 21:40:22 +01:00
jordi fita mas 5eeaab2013 Use user’ß email for auth funcs and return cookie on email change
This is for security, just in case two users have the same cookie,
althought it is unlikely, but nevertheless less guessable.

I also need to refresh the cookie when the user changes their email
address, because it is liked toghether.  It does mean that it will
logout from everywhere else, but i can not do anything about that.
2023-01-23 21:18:55 +01:00
jordi fita mas f9e22c0789 Complete the style of the profile dialog 2023-01-23 19:35:49 +01:00
jordi fita mas 22509dd683 Implement profile menu with <details>
It works better than with the weird hover behaviour i could do in CSS,
and it already has most of the aria roles needed.

The only tricky part is to allow closing it by clicking anywhere else,
that is done by “extending” the <summary> to the whole screen, with a
lower z-index than the menu but higher than the rest of controls, that
way we force people to click on that summary.
2023-01-23 18:52:18 +01:00
37 changed files with 670 additions and 225 deletions

24
deploy/build_cookie.sql Normal file
View File

@ -0,0 +1,24 @@
-- Deploy numerus:build_cookie to pg
-- requires: schema_numerus
-- requires: current_user_email
-- requires: current_user_cookie
begin;
set search_path to numerus, public;
create or replace function build_cookie(user_email email default null, user_cookie text default null) returns text as
$$
select coalesce(user_cookie, current_user_cookie()) || '/' || coalesce(user_email, current_user_email());
$$
language sql
stable;
revoke execute on function build_cookie(email, text) from public;
grant execute on function build_cookie(email, text) to invoicer;
grant execute on function build_cookie(email, text) to admin;
comment on function build_cookie(email, text) is
'Build the cookie to send to the users browser, either for the given values or for the current user.';
commit;

View File

@ -0,0 +1,28 @@
-- Deploy numerus:change_password to pg
-- requires: schema_numerus
-- requires: user
begin;
set search_path to numerus, auth, public;
create or replace function change_password(new_password text) returns void as
$$
update "user"
set password = new_password
where email = current_user_email()
and cookie = current_user_cookie()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
$$ language sql
security definer
set search_path to auth, numerus, pg_temp;
revoke execute on function change_password(text) from public;
grant execute on function change_password(text) to invoicer;
grant execute on function change_password(text) to admin;
comment on function change_password(text) is
'Changes the password for the current app user';
commit;

View File

@ -28,7 +28,6 @@ begin
user_cookie := '';
user_role := 'guest'::name;
end if;
perform set_config('request.user.id', uid, false);
perform set_config('request.user.email', user_email, false);
perform set_config('request.user.cookie', user_cookie, false);
return user_role;
@ -40,7 +39,7 @@ stable
set search_path = auth, numerus, pg_temp;
comment on function check_cookie(text) is
'Checks whether a given cookie is for a valid users, returning its email and role';
'Checks whether a given cookie is for a valid users, returning their role, and setting current_user_email and current_user_cookie';
revoke execute on function check_cookie(text) from public;
grant execute on function check_cookie(text) to authenticator;

View File

@ -1,23 +0,0 @@
-- Deploy numerus:current_app_user to pg
-- requires: schema_numerus
begin;
set search_path to numerus;
create or replace function current_app_user() returns text as
$$
select current_setting('request.user.cookie', true);
$$
language sql
stable;
comment on function current_app_user() is
'Returns the ID of the current Numerus user';
revoke execute on function current_app_user() from public;
grant execute on function current_app_user() to guest;
grant execute on function current_app_user() to invoicer;
grant execute on function current_app_user() to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy numerus:current_user_cookie to pg
-- requires: schema_numerus
begin;
set search_path to numerus;
create or replace function current_user_cookie() returns text as
$$
select current_setting('request.user.cookie', true);
$$
language sql
stable;
comment on function current_user_cookie() is
'Returns the cookie of the current Numerus user';
revoke execute on function current_user_cookie() from public;
grant execute on function current_user_cookie() to guest;
grant execute on function current_user_cookie() to invoicer;
grant execute on function current_user_cookie() to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy numerus:current_user_email to pg
-- requires: schema_numerus
begin;
set search_path to numerus;
create or replace function current_user_email() returns text as
$$
select current_setting('request.user.email', true);
$$
language sql
stable;
comment on function current_user_email() is
'Returns the email of the current Numerus user';
revoke execute on function current_user_email() from public;
grant execute on function current_user_email() to guest;
grant execute on function current_user_email() to invoicer;
grant execute on function current_user_email() to admin;
commit;

View File

@ -6,6 +6,7 @@
-- requires: email
-- requires: user
-- requires: login_attempt
-- requires: build_cookie
begin;
@ -48,7 +49,7 @@ begin
(user_name, ip_address, success)
values (login.email, login.ip_address, true);
return user_cookie || '/' || email;
return build_cookie(email, user_cookie);
end;
$$
language plpgsql

View File

@ -1,7 +1,8 @@
-- Deploy numerus:logout to pg
-- requires: schema_auth
-- requires: user
-- requires: current_app_user
-- requires: current_user_cookie
-- requires: current_user_email
begin;
@ -12,7 +13,8 @@ $$
update "user"
set cookie = default
, cookie_expires_at = default
where cookie = current_app_user()
where email = current_user_email()
and cookie = current_user_cookie()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
$$
@ -21,7 +23,7 @@ security definer
set search_path to auth, numerus, pg_temp;
comment on function logout() is
'Removes the cookie and its expiry data from the current user, set as request.user setting';
'Removes the cookie and its expiry data from the current user, as returned by current_user_email and current_user_cookie';
revoke execute on function logout() from public;
grant execute on function logout() to invoicer;

View File

@ -1,7 +1,8 @@
-- Deploy numerus:user_profile to pg
-- requires: schema_numerus
-- requires: user
-- requires: current_app_user
-- requires: current_user_cookie
-- requires: current_user_email
begin;
@ -16,7 +17,8 @@ select user_id
, role
, lang_tag
from auth."user"
where cookie = current_app_user()
where email = current_user_email()
and cookie = current_user_cookie()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
union all
@ -28,23 +30,40 @@ select 0
where not exists (
select 1
from auth."user"
where cookie = current_app_user()
where email = current_user_email()
and cookie = current_user_cookie()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
);
create rule update_user_profile as on update to user_profile
do instead update auth."user"
set email = new.email
, name = new.name
, lang_tag = new.lang_tag
where cookie = current_app_user()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
;
grant select on table user_profile to guest;
grant select, update(email, name, lang_tag) on table user_profile to invoicer;
grant select, update(email, name, lang_tag) on table user_profile to admin;
create or replace function update_user_profile() returns trigger as
$$
begin
update auth."user"
set email = new.email
, name = new.name
, lang_tag = new.lang_tag
where email = current_user_email()
and cookie = current_user_cookie()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
;
perform set_config('request.user.email', new.email, false);
return new;
end;
$$
language plpgsql
security definer
set search_path to auth, numerus, pg_temp;
create trigger update_user_profile
instead of update on user_profile
for each row execute procedure update_user_profile();
commit;

View File

@ -46,7 +46,7 @@ func LoginHandler() http.Handler {
conn := getConn(r)
cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r))
if cookie != "" {
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
setSessionCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
@ -79,6 +79,10 @@ func remoteAddr(r *http.Request) string {
return address
}
func setSessionCookie(w http.ResponseWriter, cookie string) {
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
}
func createSessionCookie(value string, duration time.Duration) *http.Cookie {
return &http.Cookie{
Name: sessionCookie,

View File

@ -30,9 +30,9 @@ func ProfileHandler() http.Handler {
conn := getConn(r)
locale := getLocale(r)
page := ProfilePage{
Title: pgettext("title", "User Settings", locale),
Email: user.Email,
Languages: mustGetLanguageOptions(r.Context(), conn),
Title: pgettext("title", "User Settings", locale),
Email: user.Email,
Language: user.Language.String(),
}
if r.Method == "POST" {
r.ParseForm()
@ -41,11 +41,16 @@ func ProfileHandler() http.Handler {
page.Password = r.FormValue("password")
page.PasswordConfirm = r.FormValue("password_confirm")
page.Language = r.FormValue("language")
conn.MustExec(r.Context(), "update user_profile set name = $1, email = $2, lang_tag = $3", page.Name, page.Email, page.Language)
http.Redirect(w, r, "/profile", http.StatusSeeOther);
return;
cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", page.Name, page.Email, page.Language)
setSessionCookie(w, cookie)
if page.Password != "" && page.Password == page.PasswordConfirm {
conn.MustExec(r.Context(), "select change_password($1)", page.Password)
}
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
} else {
if err := conn.QueryRow(r.Context(), "select name, lang_tag from user_profile").Scan(&page.Name, &page.Language); err != nil {
page.Languages = mustGetLanguageOptions(r.Context(), conn)
if err := conn.QueryRow(r.Context(), "select name from user_profile").Scan(&page.Name); err != nil {
panic(nil)
}
}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-23 00:41+0100\n"
"POT-Creation-Date: 2023-01-23 18:50+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -81,11 +81,11 @@ msgctxt "action"
msgid "Save changes"
msgstr "Desa canvis"
#: web/template/app.html:16
#: web/template/app.html:20
msgid "Account"
msgstr "Compte"
#: web/template/app.html:19
#: web/template/app.html:27
msgctxt "action"
msgid "Logout"
msgstr "Surt"

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-23 00:41+0100\n"
"POT-Creation-Date: 2023-01-23 18:50+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -81,11 +81,11 @@ msgctxt "action"
msgid "Save changes"
msgstr "Guardar cambios"
#: web/template/app.html:16
#: web/template/app.html:20
msgid "Account"
msgstr "Cuenta"
#: web/template/app.html:19
#: web/template/app.html:27
msgctxt "action"
msgid "Logout"
msgstr "Salir"

7
revert/build_cookie.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:build_cookie from pg
begin;
drop function if exists numerus.build_cookie(numerus.email, text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:change_password from pg
begin;
drop function if exists numerus.change_password(text);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:current_app_user from pg
begin;
drop function if exists numerus.current_app_user();
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:current_user_cookie from pg
begin;
drop function if exists numerus.current_user_cookie();
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:current_user_email from pg
begin;
drop function if exists numerus.current_user_email();
commit;

View File

@ -2,6 +2,8 @@
begin;
drop trigger if exists update_user_profile on numerus.user_profile;
drop function if exists numerus.update_user_profile();
drop view if exists numerus.user_profile;
commit;

View File

@ -15,9 +15,12 @@ extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita mas <jordi@tand
encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita mas <jordi@tandem.blog> # Add trigger to encrypt users password
login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas <jordi@tandem.blog> # Add table to log login attempts
login [roles schema_numerus schema_auth extension_pgcrypto email user login_attempt] 2023-01-13T00:32:32Z jordi fita mas <jordi@tandem.blog> # Add function to login
check_cookie [schema_public user] 2023-01-17T17:48:49Z jordi fita mas <jordi@tandem.blog> # Add function to check if a user cookie is valid
current_app_user [schema_numerus] 2023-01-21T20:16:28Z jordi fita mas <jordi@tandem.blog> # Add function to get the ID of the current Numerus user
logout [schema_auth current_app_user user] 2023-01-17T19:10:21Z jordi fita mas <jordi@tandem.blog> # Add function to logout
current_user_cookie [schema_numerus] 2023-01-21T20:16:28Z jordi fita mas <jordi@tandem.blog> # Add function to get the cookie of the current Numerus user
current_user_email [schema_numerus] 2023-01-23T19:11:53Z jordi fita mas <jordi@tandem.blog> # Add function to get the email of the current Numerus user
build_cookie [schema_numerus current_user_email current_user_cookie] 2023-01-23T19:46:13Z jordi fita mas <jordi@tandem.blog> # Add function to build the cookie for the current user
check_cookie [schema_public user build_cookie] 2023-01-17T17:48:49Z jordi fita mas <jordi@tandem.blog> # Add function to check if a user cookie is valid
logout [schema_auth current_user_email current_user_cookie user] 2023-01-17T19:10:21Z jordi fita mas <jordi@tandem.blog> # Add function to logout
set_cookie [schema_public check_cookie] 2023-01-19T11:00:22Z jordi fita mas <jordi@tandem.blog> # Add function to set the role based on the cookie
available_languages [schema_numerus language] 2023-01-21T21:11:08Z jordi fita mas <jordi@tandem.blog> # Add the initial available languages
user_profile [schema_numerus user current_app_user] 2023-01-21T23:18:20Z jordi fita mas <jordi@tandem.blog> # Add view for user profile
user_profile [schema_numerus user current_user_email current_user_cookie] 2023-01-21T23:18:20Z jordi fita mas <jordi@tandem.blog> # Add view for user profile
change_password [schema_numerus user] 2023-01-23T20:22:45Z jordi fita mas <jordi@tandem.blog> # Add function to change the current users password

70
test/build_cookie.sql Normal file
View File

@ -0,0 +1,70 @@
-- Test build_cookie
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(13);
set search_path to numerus, auth, public;
select has_function('numerus', 'build_cookie', array ['email', 'text']);
select function_lang_is('numerus', 'build_cookie', array ['email', 'text'], 'sql');
select function_returns('numerus', 'build_cookie', array ['email', 'text'], 'text');
select isnt_definer('numerus', 'build_cookie', array ['email', 'text']);
select volatility_is('numerus', 'build_cookie', array ['email', 'text'], 'stable');
select function_privs_are('numerus', 'build_cookie', array ['email', 'text'], 'guest', array []::text[]);
select function_privs_are('numerus', 'build_cookie', array ['email', 'text'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'build_cookie', array ['email', 'text'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'build_cookie', array ['email', 'text'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (9, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
select is(
build_cookie('test@example.com'::email, '123abc'),
'123abc/test@example.com',
'Should build the cookie with the given user and cookie value'
);
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog');
reset role;
select is(
build_cookie(),
'44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog',
'Should build the cookie for the logged in user'
);
select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog');
reset role;
select is(
build_cookie(),
'12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog',
'Should build the cookie for the other logged in user'
);
select set_cookie('ashtasth');
reset role;
select is(
build_cookie(),
'/',
'Should build the cookie for the guest user'
);
select *
from finish();
rollback;

57
test/change_password.sql Normal file
View File

@ -0,0 +1,57 @@
-- Test change_password
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(14);
set search_path to numerus, auth, public;
select has_function('numerus', 'change_password', array ['text']);
select function_lang_is('numerus', 'change_password', array ['text'], 'sql');
select function_returns('numerus', 'change_password', array ['text'], 'void');
select is_definer('numerus', 'change_password', array ['text']);
select volatility_is('numerus', 'change_password', array ['text'], 'volatile');
select function_privs_are('numerus', 'change_password', array ['text'], 'guest', array []::text[]);
select function_privs_are('numerus', 'change_password', array ['text'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'change_password', array ['text'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'change_password', array ['text'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (9, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
select lives_ok( $$ select change_password('another') $$, 'Should run even without current user' );
select isnt_empty (
$$ select * from auth."user" where password = crypt('test', password) $$,
'Should not have changed any password'
);
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog');
select lives_ok( $$ select change_password('another') $$, 'Should run with the correct user' );
reset role;
select isnt_empty (
$$ select * from auth."user" where email = 'demo@tandem.blog' and password = crypt('another', password) $$,
'Should have changed the password of the current user'
);
select isnt_empty (
$$ select * from auth."user" where email = 'admin@tandem.blog' and password = crypt('test', password) $$,
'Should not have changed any other password'
);
select *
from finish();
rollback;

View File

@ -29,10 +29,7 @@ values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4b
;
prepare user_info as
select current_setting('request.user.id', true)::integer
, current_setting('request.user.email', true)
, current_setting('request.user.cookie', true)
;
select current_user_email(), current_user_cookie();
select is (
check_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'),
@ -42,7 +39,7 @@ select is (
select results_eq (
'user_info',
$$ values (1, 'demo@tandem.blog', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e') $$,
$$ values ('demo@tandem.blog', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e') $$,
'Should have updated the settings with the user info'
);
@ -54,7 +51,7 @@ select is (
select results_eq (
'user_info',
$$ values (9, 'admin@tandem.blog', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524') $$,
$$ values ('admin@tandem.blog', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524') $$,
'Should have updated the settings with the other user info'
);
@ -66,7 +63,7 @@ select is (
select results_eq (
'user_info',
$$ values (0, '', '') $$,
$$ values ('', '') $$,
'Should have updated the settings with a guest user'
);
@ -78,7 +75,7 @@ select is (
select results_eq (
'user_info',
$$ values (0, '', '') $$,
$$ values ('', '') $$,
'Should have left the settings with a guest user'
);
@ -92,7 +89,7 @@ select is (
select results_eq (
'user_info',
$$ values (0, '', '') $$,
$$ values ('', '') $$,
'Should have left the settings with a guest user'
);
@ -104,7 +101,7 @@ select is (
select results_eq (
'user_info',
$$ values (0, '', '') $$,
$$ values ('', '') $$,
'Should have left the settings with a guest user'
);

View File

@ -1,62 +0,0 @@
-- Test current_app_user
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to numerus, auth, public;
select plan(15);
select has_function('numerus', 'current_app_user', array []::name[]);
select function_lang_is('numerus', 'current_app_user', array []::name[], 'sql');
select function_returns('numerus', 'current_app_user', array []::name[], 'text');
select isnt_definer('numerus', 'current_app_user', array []::name[]);
select volatility_is('numerus', 'current_app_user', array []::name[], 'stable');
select function_privs_are('numerus', 'current_app_user', array []::name[], 'guest', array ['EXECUTE']);
select function_privs_are('numerus', 'current_app_user', array []::name[], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'current_app_user', array []::name[], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'current_app_user', array []::name[], 'authenticator', array []::text []);
set client_min_messages to warning;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
select lives_ok(
$$ select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') $$,
'Should change ok for the first user'
);
select is(current_app_user(), '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', 'Should be running as the first user');
reset role;
select lives_ok(
$$ select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog') $$,
'Should change ok for the second user'
);
select is(current_app_user(), '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', 'Should be running as the second user');
reset role;
select lives_ok(
$$ select set_cookie('') $$,
'Should change ok for a guest user'
);
select is(current_app_user(), '', 'Should be running as the first user');
reset role;
select *
from finish();
rollback;

View File

@ -0,0 +1,62 @@
-- Test current_user_cookie
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to numerus, auth, public;
select plan(15);
select has_function('numerus', 'current_user_cookie', array []::name[]);
select function_lang_is('numerus', 'current_user_cookie', array []::name[], 'sql');
select function_returns('numerus', 'current_user_cookie', array []::name[], 'text');
select isnt_definer('numerus', 'current_user_cookie', array []::name[]);
select volatility_is('numerus', 'current_user_cookie', array []::name[], 'stable');
select function_privs_are('numerus', 'current_user_cookie', array []::name[], 'guest', array ['EXECUTE']);
select function_privs_are('numerus', 'current_user_cookie', array []::name[], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'current_user_cookie', array []::name[], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'current_user_cookie', array []::name[], 'authenticator', array []::text []);
set client_min_messages to warning;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
select lives_ok(
$$ select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') $$,
'Should change ok for the first user'
);
select is(current_user_cookie(), '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', 'Should return the cookie of the first user');
reset role;
select lives_ok(
$$ select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog') $$,
'Should change ok for the second user'
);
select is(current_user_cookie(), '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', 'Should return the cookie of the second user');
reset role;
select lives_ok(
$$ select set_cookie('') $$,
'Should change ok for a guest user'
);
select is(current_user_cookie(), '', 'Should return an empty string');
reset role;
select *
from finish();
rollback;

View File

@ -0,0 +1,63 @@
-- Test current_user_email
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to numerus, auth, public;
select plan(15);
select has_function('numerus', 'current_user_email', array []::name[]);
select function_lang_is('numerus', 'current_user_email', array []::name[], 'sql');
select function_returns('numerus', 'current_user_email', array []::name[], 'text');
select isnt_definer('numerus', 'current_user_email', array []::name[]);
select volatility_is('numerus', 'current_user_email', array []::name[], 'stable');
select function_privs_are('numerus', 'current_user_email', array []::name[], 'guest', array ['EXECUTE']);
select function_privs_are('numerus', 'current_user_email', array []::name[], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'current_user_email', array []::name[], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'current_user_email', array []::name[], 'authenticator', array []::text []);
set client_min_messages to warning;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
select lives_ok(
$$ select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') $$,
'Should change ok for the first user'
);
select is(current_user_email(), 'demo@tandem.blog', 'Should return the email of the first user');
reset role;
select lives_ok(
$$ select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog') $$,
'Should change ok for the second user'
);
select is(current_user_email(), 'admin@tandem.blog', 'Should return the email of the second user');
reset role;
select lives_ok(
$$ select set_cookie('') $$,
'Should change ok for a guest user'
);
select is(current_user_email(), '', 'Should return an empty string');
reset role;
select *
from finish();
rollback;

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(15);
select plan(17);
set search_path to auth, numerus, public;
@ -33,6 +33,7 @@ select cookie, cookie_expires_at from "user" order by user_id
;
select set_config('request.user.cookie', '', false);
select set_config('request.user.email', '', false);
select lives_ok( $$ select * from logout() $$, 'Can logout “nobody”' );
select results_eq(
@ -40,10 +41,23 @@ select results_eq(
$$ values ('8c23d4a8d777775f8fc507676a0d99d3dfa54b03b1b257c838', current_timestamp + interval '1 day')
, ('0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', current_timestamp + interval '2 day')
$$,
'Nothing changed'
'Should have changed nothing'
);
select set_config('request.user.cookie', '0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', false);
select set_config('request.user.email', 'info@tandem.blog', false);
select lives_ok( $$ select * from logout() $$, 'Can logout even if the email and cookie does not match' );
select results_eq(
'user_cookies',
$$ values ('8c23d4a8d777775f8fc507676a0d99d3dfa54b03b1b257c838', current_timestamp + interval '1 day')
, ('0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', current_timestamp + interval '2 day')
$$,
'Should have changed nothing'
);
select set_config('request.user.cookie', '8c23d4a8d777775f8fc507676a0d99d3dfa54b03b1b257c838', false);
select set_config('request.user.email', 'info@tandem.blog', false);
select lives_ok( $$ select * from logout() $$, 'Can logout the first user' );
select results_eq(
@ -55,6 +69,7 @@ select results_eq(
);
select set_config('request.user.cookie', '0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', false);
select set_config('request.user.email', 'admin@tandem.blog', false);
select lives_ok( $$ select * from logout() $$, 'Can logout the second user' );
select results_eq(

View File

@ -29,7 +29,7 @@ values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4b
;
prepare user_info as
select current_setting('request.user.id', true)::integer, current_setting('request.user.email', true), current_user;
select current_user_email(), current_user_cookie(), current_user;
select lives_ok(
$$ select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') $$,
@ -38,7 +38,7 @@ select lives_ok(
select results_eq(
'user_info',
$$ values (1, 'demo@tandem.blog', 'invoicer'::name) $$,
$$ values ('demo@tandem.blog', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', 'invoicer'::name) $$,
'Should have updated the info with the correct user'
);
@ -51,7 +51,7 @@ select lives_ok(
select results_eq(
'user_info',
$$ values (5, 'admin@tandem.blog', 'admin'::name) $$,
$$ values ('admin@tandem.blog', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', 'admin'::name) $$,
'Should have updated the info with the other user'
);
@ -64,7 +64,7 @@ select lives_ok(
select results_eq(
'user_info',
$$ values (0, '', 'guest'::name) $$,
$$ values ('', '', 'guest'::name) $$,
'Should have updated the info as a guest user'
);

7
verify/build_cookie.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:build_cookie on pg
begin;
select has_function_privilege('numerus.build_cookie(numerus.email, text)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:change_password on pg
begin;
select has_function_privilege('numerus.change_password(text)', 'execute');
rollback;

View File

@ -1,7 +0,0 @@
-- Verify numerus:current_app_user on pg
begin;
select has_function_privilege('numerus.current_app_user()', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:current_user_cookie on pg
begin;
select has_function_privilege('numerus.current_user_cookie()', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:current_user_email on pg
begin;
select has_function_privilege('numerus.current_user_email()', 'execute');
rollback;

View File

@ -11,4 +11,35 @@ select
from numerus.user_profile
where false;
select has_function_privilege('numerus.update_user_profile()', 'execute');
select 1/count(*)
from pg_trigger
where not tgisinternal
and tgname = 'update_user_profile'
and tgrelid = 'numerus.user_profile'::regclass
and tgtype = b'01010001'::int;
-- │││││││
-- ││││││└─> row
-- │││││└──> before
-- ││││└───> insert
-- │││└────> delete
-- ││└─────> update
-- │└──────> truncate
-- └───────> instead
select 1/count(*)
from pg_trigger
where not tgisinternal
and tgname = 'encrypt_password'
and tgrelid = 'auth.user'::regclass
and tgtype = b'00010111'::int;
-- │││││││
-- ││││││└─> row
-- │││││└──> before
-- ││││└───> insert
-- │││└────> delete
-- ││└─────> update
-- │└──────> truncate
-- └───────> instead
rollback;

View File

@ -189,6 +189,7 @@ p, h1, h2, h3, h4, h5, h6 {
}
input[type="submit"], button {
min-width: 34rem;
background-color: var(--numerus--color--white);
border: 2px solid var(--numerus--color--black);
text-transform: uppercase;
@ -279,11 +280,59 @@ input[type="text"], input[type="password"], input[type="email"], select {
transition: 0.2s;
}
.relative {
fieldset {
border: none;
padding: 2rem 0 0;
margin-top: 3rem;
border-top: 1px solid var(--numerus--color--light-gray);
}
legend {
float: left;
font-style: italic;
margin-bottom: 3rem;
width: 100%;
}
legend + * {
clear: both;
}
fieldset {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.full-width {
gap: 2rem;
}
.full-width legend {
margin-bottom: initial;
}
.full-width .input {
flex: 1;
}
.full-width input {
width: 100%;
}
.dialog-content {
max-width: 120rem;
margin: 0 auto;
}
/* Profile Menu */
#profilemenu {
position: relative;
}
#profilebutton {
#profilemenu summary {
width: 7rem;
height: 7rem;
margin: 1rem 0;
@ -292,45 +341,41 @@ input[type="text"], input[type="password"], input[type="email"], select {
align-items: center;
border-radius: 50%;
border: none;
list-style: none;
}
#profilebutton, #profilemenu button {
#profilemenu summary::-webkit-details-marker {
display: none;
}
#profilemenu summary, #profilemenu button {
cursor: pointer;
}
#profilemenu {
#profilemenu summary, #profilemenu ul {
background-color: var(--numerus--background-color);
}
#profilemenu[open] summary::before {
background-color: var(--numerus--header--background-color);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
content: "";
cursor: default;
z-index: 10;
mix-blend-mode: multiply;
}
#profilemenu ul {
list-style: none;
position: absolute;
right: -1.875em;
top: 100%;
padding: 1rem 2rem;
background-color: var(--numerus--color--white);
display: none;
opacity: 0;
z-index: 10;
}
header div:hover #profilemenu {
opacity: 1;
display: initial;
}
header .overlay {
background-color: var(--numerus--header--background-color);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0;
display: none;
pointer-events: none;
mix-blend-mode: multiply;
}
header div:hover + .overlay {
display: block;
opacity: 1;
z-index: 20;
}
#profilemenu li + li {
@ -351,12 +396,12 @@ header div:hover + .overlay {
text-transform: initial;
}
#profilemenu i[class^='ri-'] {
#profilemenu li i[class^='ri-'] {
margin-right: 2rem;
color: var(--numerus--color--dark-gray);
}
#profilemenu button:hover, #profilemenu a:hover {
#profilemenu summary:hover, #profilemenu summary:focus, #profilemenu button:hover, #profilemenu a:hover {
background-color: var(--numerus--color--light-gray);
}

View File

@ -9,18 +9,27 @@
<body>
<header>
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
<div class="relative">
<button id="profilebutton" aria-controls="profilemenu" aria-haspopup="true"><i class="ri-eye-close-line ri-3x"></i></button>
<ul id="profilemenu" role="menu" aria-labelledby="profilebutton">
<details id="profilemenu">
<summary>
<i class="ri-eye-close-line ri-3x"></i>
</summary>
<ul role="menu">
<li role="presentation">
<a role="menuitem" href="/profile"><i class="ri-account-circle-line"></i> {{( gettext "Account" )}}</a>
<a role="menuitem" href="/profile">
<i class="ri-account-circle-line"></i>
{{( gettext "Account" )}}
</a>
</li>
<li role="presentation">
<form method="POST" action="/logout"><button type="submit" role="menuitem"><i class="ri-logout-circle-line"></i> {{( pgettext "Logout" "action" )}}</button></form>
<form method="POST" action="/logout">
<button type="submit" role="menuitem">
<i class="ri-logout-circle-line"></i>
{{( pgettext "Logout" "action" )}}
</button>
</form>
</li>
</ul>
</div>
<div class="overlay"></div>
</details>
</header>
<main>
{{- template "content" . }}

View File

@ -1,40 +1,46 @@
{{ define "content" }}
<h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST" action="/profile">
<fieldset>
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
<section class="dialog-content">
<h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST" action="/profile">
<fieldset class="full-width">
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
<div class="input">
<input type="text" name="name" id="name" required="required" value="{{ .Name }}" placeholder="{{( pgettext "User name" "input" )}}">
<label for="name">{{( pgettext "User name" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="name" id="name" required="required" value="{{ .Name }}" placeholder="{{( pgettext "User name" "input" )}}">
<label for="name">{{( pgettext "User name" "input" )}}</label>
</div>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
</fieldset>
<fieldset>
<legend>{{( pgettext "Password Change" "title" )}}</legend>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
</fieldset>
<fieldset class="full-width">
<legend>{{( pgettext "Password Change" "title" )}}</legend>
<div class="input">
<input type="password" name="password" id="password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}">
<label for="password">{{( pgettext "Password" "input" )}}</label>
</div>
<div class="input">
<input type="password" name="password" id="password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}">
<label for="password">{{( pgettext "Password" "input" )}}</label>
</div>
<div class="input">
<input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}" placeholder="{{( pgettext "Password Confirmation" "input" )}}">
<label for="password_confirm">{{( pgettext "Password Confirmation" "input" )}}</label>
</div>
</fieldset>
<div class="input">
<input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}" placeholder="{{( pgettext "Password Confirmation" "input" )}}">
<label for="password_confirm">{{( pgettext "Password Confirmation" "input" )}}</label>
</div>
</fieldset>
<label for="language">{{( pgettext "Language" "input" )}}</label>
<select id="language" name="language">
<option value="und">{{( pgettext "Automatic" "language option" )}}</option>
{{- range $language := .Languages }}
<option value="{{ .Tag }}" {{ if eq .Tag $.Language }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }}
</select>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</form>
<fieldset>
<legend id="language-legend">{{( pgettext "Language" "input" )}}</legend>
<select id="language" name="language" aria-labelledby="language-legend">
<option value="und">{{( pgettext "Automatic" "language option" )}}</option>
{{- range $language := .Languages }}
<option value="{{ .Tag }}" {{ if eq .Tag $.Language }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }}
</select>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</fieldset>
</form>
</section>
{{- end }}