From f1bf1f896de9a58feab7f0d38ca19f9ec3d88f0c Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Tue, 17 Jan 2023 20:48:50 +0100 Subject: [PATCH] Implement login cookie, its verification, and logout At first i thought that i would need to implement sessions, the ones that keep small files onto the disk, to know which user is talking to the server, but then i realized that, for now at least, i only need a very large number, plus the email address, to be used as a lookup, and that can be stored in the user table, in a separate schema. Had to change login to avoid raising exceptions when login failed because i now keep a record of login attemps, and functions are always run in a single transaction, thus the exception would prevent me to insert into login_attempt. Even if i use a separate procedure, i could not keep the records. I did not want to add a parameter to the logout function because i was afraid that it could be called from separate users. I do not know whether it is possible with the current approach, since the settings variable is also set by the same applications; time will tell. --- cmd/numerus/main.go | 6 +- deploy/check_cookie.sql | 39 +++++++++++++ deploy/login.sql | 55 ++++++++++++------ deploy/login_attempt.sql | 16 ++++++ deploy/logout.sql | 27 +++++++++ deploy/user.sql | 2 + pkg/db.go | 50 +++++++++++++++-- pkg/login.go | 117 +++++++++++++++++++++++++++++++++++++++ pkg/recover.go | 29 ++++++++++ pkg/router.go | 45 +++++++-------- revert/check_cookie.sql | 7 +++ revert/login.sql | 2 +- revert/login_attempt.sql | 7 +++ revert/logout.sql | 7 +++ sqitch.plan | 5 +- test/check_cookie.sql | 73 ++++++++++++++++++++++++ test/login.sql | 98 ++++++++++++++++++++++++-------- test/login_attempt.sql | 50 +++++++++++++++++ test/logout.sql | 71 ++++++++++++++++++++++++ test/user.sql | 14 ++++- verify/check_cookie.sql | 7 +++ verify/login.sql | 2 +- verify/login_attempt.sql | 13 +++++ verify/logout.sql | 7 +++ verify/user.sql | 2 + web/template/index.html | 12 +--- web/template/login.html | 24 ++++++++ 27 files changed, 702 insertions(+), 85 deletions(-) create mode 100644 deploy/check_cookie.sql create mode 100644 deploy/login_attempt.sql create mode 100644 deploy/logout.sql create mode 100644 pkg/login.go create mode 100644 pkg/recover.go create mode 100644 revert/check_cookie.sql create mode 100644 revert/login_attempt.sql create mode 100644 revert/logout.sql create mode 100644 test/check_cookie.sql create mode 100644 test/login_attempt.sql create mode 100644 test/logout.sql create mode 100644 verify/check_cookie.sql create mode 100644 verify/login_attempt.sql create mode 100644 verify/logout.sql create mode 100644 web/template/login.html diff --git a/cmd/numerus/main.go b/cmd/numerus/main.go index 0abd24f..4cc9967 100644 --- a/cmd/numerus/main.go +++ b/cmd/numerus/main.go @@ -13,15 +13,15 @@ import ( ) func main() { - dbpool, err := numerus.ConnectToDatabase(context.Background(), os.Getenv("NUMERUS_DATABASE_URL")) + db, err := numerus.NewDatabase(context.Background(), os.Getenv("NUMERUS_DATABASE_URL")) if err != nil { log.Fatal(err) } - defer dbpool.Close() + defer db.Close() srv := http.Server{ Addr: ":8080", - Handler: numerus.NewRouter(dbpool), + Handler: numerus.NewRouter(db), ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute, diff --git a/deploy/check_cookie.sql b/deploy/check_cookie.sql new file mode 100644 index 0000000..f17a6cb --- /dev/null +++ b/deploy/check_cookie.sql @@ -0,0 +1,39 @@ +-- Deploy numerus:check_cookie to pg +-- requires: schema_auth +-- requires: user + +begin; + +set search_path to numerus, auth, public; + +create or replace function check_cookie(input_cookie text) returns record as +$$ +declare + value record; +begin + select email::text, role + into value + from "user" + where email = split_part(input_cookie, '/', 2) + and cookie_expires_at > current_timestamp + and length(password) > 0 + and cookie = split_part(input_cookie, '/', 1) + ; + if value is null then + select '', 'guest'::name into value; + end if; + return value; +end; +$$ +language plpgsql +security definer +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'; + +revoke execute on function check_cookie(text) from public; +grant execute on function check_cookie(text) to authenticator; + +commit; diff --git a/deploy/login.sql b/deploy/login.sql index 6299fe4..94188c1 100644 --- a/deploy/login.sql +++ b/deploy/login.sql @@ -2,40 +2,63 @@ -- requires: roles -- requires: schema_numerus -- requires: schema_auth +-- requires: extension_pgcrypto -- requires: email -- requires: user +-- requires: login_attempt begin; set search_path to numerus, auth; -create or replace function login(email email, password text) returns name as +create or replace function login(email email, password text, ip_address inet default null) returns text as $$ declare - user_role name; + user_cookie text; begin - select role - into user_role - from "user" - where "user".email = login.email - and "user".password = crypt(login.password, "user".password); - - if user_role is null then - raise invalid_password using message = 'invalid user or password'; + if not exists ( + select * + from "user" + where "user".email = login.email + and "user".password = crypt(login.password, "user".password) + ) then + insert into login_attempt + (user_name, ip_address, success) + values (login.email, login.ip_address, false); + return ''; end if; - return user_role; + select cookie + into user_cookie + from "user" + where "user".email = login.email + and cookie_expires_at > current_timestamp + and length(cookie) > 30; + + if user_cookie is null then + select encode(gen_random_bytes(25), 'hex') into user_cookie; + end if; + + update "user" + set cookie = user_cookie + , cookie_expires_at = current_timestamp + interval '1 year' + where "user".email = login.email; + + insert into login_attempt + (user_name, ip_address, success) + values (login.email, login.ip_address, true); + + return user_cookie || '/' || email; end; $$ language plpgsql -stable security definer set search_path = auth, numerus, pg_temp; -comment on function login(email, text) is -'Checks that the email and password pair is valid and returns the user’s databasse role.'; +comment on function login(email, text, inet) is +'Tries to logs a user in, recording the attempt, and returns the cookie to send back to the user if the authentication was successfull.'; -revoke execute on function login(email, text) from public; -grant execute on function login(email, text) to guest; +revoke execute on function login(email, text, inet) from public; +grant execute on function login(email, text, inet) to guest; commit; diff --git a/deploy/login_attempt.sql b/deploy/login_attempt.sql new file mode 100644 index 0000000..c4d79fe --- /dev/null +++ b/deploy/login_attempt.sql @@ -0,0 +1,16 @@ +-- Deploy numerus:login_attempt to pg +-- requires: schema_auth + +begin; + +set search_path to auth; + +create table login_attempt ( + attempt_id bigserial primary key + , user_name text not null + , ip_address inet -- just in case we logged from a non web application, somehow + , success boolean not null + , attempted_at timestamptz not null default current_timestamp +); + +commit; diff --git a/deploy/logout.sql b/deploy/logout.sql new file mode 100644 index 0000000..4c74cbb --- /dev/null +++ b/deploy/logout.sql @@ -0,0 +1,27 @@ +-- Deploy numerus:logout to pg +-- requires: schema_auth +-- requires: user + +begin; + +set search_path to numerus, auth, public; + +create or replace function logout() returns void as +$$ +update "user" +set cookie = default + , cookie_expires_at = default +where email = current_setting('request.user') +$$ +language sql +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'; + +revoke execute on function logout() from public; +grant execute on function logout() to invoicer; +grant execute on function logout() to admin; + +commit; diff --git a/deploy/user.sql b/deploy/user.sql index c839fae..436e5c2 100644 --- a/deploy/user.sql +++ b/deploy/user.sql @@ -13,6 +13,8 @@ create table "user" ( name text not null, password text not null check (length(password) < 512), role name not null check (length(role) < 512), + cookie text not null default '', + cookie_expires_at timestamptz not null default '-infinity'::timestamp, created_at timestamptz not null default current_timestamp ); diff --git a/pkg/db.go b/pkg/db.go index 550c02d..5568a13 100644 --- a/pkg/db.go +++ b/pkg/db.go @@ -3,12 +3,17 @@ package pkg import ( "context" "log" + "net/http" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" ) -func ConnectToDatabase(ctx context.Context, connString string) (*pgxpool.Pool, error) { +type Db struct { + pool *pgxpool.Pool +} + +func NewDatabase(ctx context.Context, connString string) (*Db, error) { config, err := pgxpool.ParseConfig(connString) if err != nil { log.Fatal(err) @@ -20,9 +25,18 @@ func ConnectToDatabase(ctx context.Context, connString string) (*pgxpool.Pool, e } config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { - if _, err := conn.Exec(ctx, "select set_config('role', $1, false)", "guest"); err != nil { - log.Printf("ERROR - Failed to set role: %v", err) - return false + if user, ok := ctx.Value(ContextUserKey).(*AppUser); ok { + batch := &pgx.Batch{} + batch.Queue("select set_config('request.user', $1, false)", user.Email) + batch.Queue("select set_config('role', $1, false)", user.Role) + br := conn.SendBatch(ctx, batch) + defer br.Close() + for i := 0; i < batch.Len(); i++ { + if _, err := br.Exec(); err != nil { + log.Printf("ERROR - Failed to set role: %v", err) + return false + } + } } return true } @@ -35,5 +49,31 @@ func ConnectToDatabase(ctx context.Context, connString string) (*pgxpool.Pool, e return true } - return pgxpool.ConnectConfig(ctx, config) + pool, err := pgxpool.ConnectConfig(ctx, config) + if err != nil { + return nil, err + } + return &Db{pool}, nil +} + +func (db *Db) Close() { + db.pool.Close() +} + +func (db *Db) Text(r *http.Request, def string, sql string, args ...interface{}) string { + var result string + if err := db.pool.QueryRow(r.Context(), sql, args...).Scan(&result); err != nil { + if err == pgx.ErrNoRows { + return def + } + panic(err) + } + + return result +} + +func (db *Db) Exec(r *http.Request, sql string, args ...interface{}) { + if _, err := db.pool.Exec(r.Context(), sql, args...); err != nil { + panic(err) + } } diff --git a/pkg/login.go b/pkg/login.go new file mode 100644 index 0000000..48792fc --- /dev/null +++ b/pkg/login.go @@ -0,0 +1,117 @@ +package pkg + +import ( + "context" + "html/template" + "net" + "net/http" + "time" + + "github.com/jackc/pgx/v4" +) + +const ( + ContextUserKey = "numerus-user" + sessionCookie = "numerus-session" + defaultRole = "guest" +) + +type LoginPage struct { + LoginError string + Email string + Password string +} + +type AppUser struct { + Email string + LoggedIn bool + Role string +} + +func LoginHandler(db *Db) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := getUser(r) + if user.LoggedIn { + http.Redirect(w, r, "/", http.StatusSeeOther) + } else { + r.ParseForm() + + page := LoginPage{ + Email: r.FormValue("email"), + Password: r.FormValue("password"), + } + cookie := db.Text(r, "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r)) + if cookie == "" { + page.LoginError = "Invalid user or password" + w.WriteHeader(http.StatusUnauthorized) + t, err := template.ParseFiles("web/template/login.html") + if err != nil { + panic(err) + } + err = t.Execute(w, page) + if err != nil { + panic(err) + } + } else { + http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour)) + http.Redirect(w, r, "/", http.StatusSeeOther) + } + } + }) +} + +func LogoutHandler(db *Db) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := getUser(r) + if user.LoggedIn { + db.Exec(r, "select logout()") + http.SetCookie(w, createSessionCookie("", -24*time.Hour)) + } + http.Redirect(w, r, "/", http.StatusSeeOther) + }) +} + +func remoteAddr(r *http.Request) string { + address := r.Header.Get("X-Forwarded-For") + if address == "" { + address, _, _ = net.SplitHostPort(r.RemoteAddr) + } + return address +} + +func createSessionCookie(value string, duration time.Duration) *http.Cookie { + return &http.Cookie{ + Name: sessionCookie, + Value: value, + Path: "/", + Expires: time.Now().Add(duration), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } +} + +func CheckLogin(db *Db, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := &AppUser{ + Email: "", + LoggedIn: false, + Role: defaultRole, + } + if cookie, err := r.Cookie(sessionCookie); err == nil { + row := db.pool.QueryRow(r.Context(), "select * from check_cookie($1) as (email text, role name)", cookie.Value) + if err := row.Scan(&user.Email, &user.Role); err != nil { + if err != pgx.ErrNoRows { + panic(err) + } + } else { + user.LoggedIn = user.Role != "guest" + } + } + ctx := context.WithValue(r.Context(), ContextUserKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getUser(r *http.Request) *AppUser { + return r.Context().Value(ContextUserKey).(*AppUser) +} diff --git a/pkg/recover.go b/pkg/recover.go new file mode 100644 index 0000000..0e8d08d --- /dev/null +++ b/pkg/recover.go @@ -0,0 +1,29 @@ +package pkg + +import ( + "net/http" + "runtime" + "log" + "fmt" +) + +func Recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + panic(r); + } + err, ok := r.(error) + if ! ok { + err = fmt.Errorf("%v", r); + } + stack := make([]byte, 4 << 10); + length := runtime.Stack(stack, true) + log.Printf("PANIC - %v %s", err, stack[:length]) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }(); + next.ServeHTTP(w, r); + }); +} diff --git a/pkg/router.go b/pkg/router.go index 239736f..1e959ec 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -2,44 +2,39 @@ package pkg import ( "html/template" - "log" "net/http" - - "github.com/jackc/pgx/v4/pgxpool" ) -func NewRouter(db *pgxpool.Pool) http.Handler { +func NewRouter(db *Db) http.Handler { router := http.NewServeMux() - router.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - - email := r.FormValue("email") - password := r.FormValue("password") - var role string - err := db.QueryRow(r.Context(), "select login($1, $2)", email, password).Scan(&role) - if err != nil { - log.Printf("ERROR - %v for %q", err, email) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - w.Write([]byte(role)) - }) + router.Handle("/login", LoginHandler(db)) + router.Handle("/logout", LogoutHandler(db)) router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + user := getUser(r) + if user.LoggedIn { t, err := template.ParseFiles("web/template/index.html") if err != nil { - log.Printf("ERROR - %s", err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + panic(err) } err = t.Execute(w, nil) if err != nil { - log.Printf("ERROR - %s", err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + panic(err) } + } else { + var page LoginPage; + t, err := template.ParseFiles("web/template/login.html") + if err != nil { + panic(err) + } + err = t.Execute(w, page) + if err != nil { + panic(err) + } + } }) var handler http.Handler = router + handler = CheckLogin(db, handler) + handler = Recoverer(handler) handler = Logger(handler) return handler } diff --git a/revert/check_cookie.sql b/revert/check_cookie.sql new file mode 100644 index 0000000..dda09cd --- /dev/null +++ b/revert/check_cookie.sql @@ -0,0 +1,7 @@ +-- Revert numerus:check_cookie from pg + +begin; + +drop function if exists numerus.check_cookie(text); + +commit; diff --git a/revert/login.sql b/revert/login.sql index 094609c..0e801c0 100644 --- a/revert/login.sql +++ b/revert/login.sql @@ -2,6 +2,6 @@ begin; -drop function if exists numerus.login(numerus.email, text); +drop function if exists numerus.login(numerus.email, text, inet); commit; diff --git a/revert/login_attempt.sql b/revert/login_attempt.sql new file mode 100644 index 0000000..390e6e6 --- /dev/null +++ b/revert/login_attempt.sql @@ -0,0 +1,7 @@ +-- Revert numerus:login_attempt from pg + +begin; + +drop table if exists auth.login_attempt; + +commit; diff --git a/revert/logout.sql b/revert/logout.sql new file mode 100644 index 0000000..2bf936e --- /dev/null +++ b/revert/logout.sql @@ -0,0 +1,7 @@ +-- Revert numerus:logout from pg + +begin; + +drop function if exists numerus.logout(); + +commit; diff --git a/sqitch.plan b/sqitch.plan index ef52887..82a0352 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -12,4 +12,7 @@ user [roles schema_auth email] 2023-01-12T23:44:03Z jordi fita i mas # Add trigger to ensure the user’s role exists extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita i mas # Add pgcrypto extension encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita i mas # Add trigger to encrypt user’s password -login [roles schema_numerus schema_auth email user] 2023-01-13T00:32:32Z jordi fita i mas # Add function to login +login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita i mas # 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 i mas # Add function to login +check_cookie [schema_auth user] 2023-01-17T17:48:49Z jordi fita i mas # Add function to check if a user cookie is valid +logout [schema_auth user] 2023-01-17T19:10:21Z jordi fita i mas # Add function to logout diff --git a/test/check_cookie.sql b/test/check_cookie.sql new file mode 100644 index 0000000..c378ab2 --- /dev/null +++ b/test/check_cookie.sql @@ -0,0 +1,73 @@ +-- Test check_cookie +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(15); + +set search_path to auth, numerus, public; + +select has_function('check_cookie'); +select function_lang_is('check_cookie', array ['text'], 'plpgsql'); +select function_returns('check_cookie', array ['text'], 'record'); +select is_definer('check_cookie', array ['text']); +select volatility_is('check_cookie', array ['text'], 'stable'); +select function_privs_are('check_cookie', array ['text'], 'guest', array []::text[]); +select function_privs_are('check_cookie', array ['text'], 'invoicer', array []::text[]); +select function_privs_are('check_cookie', array ['text'], 'admin', array []::text[]); +select function_privs_are('check_cookie', array ['text'], 'authenticator', array ['EXECUTE']); + +set client_min_messages to warning; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (email, name, password, role, cookie, cookie_expires_at) +values ('demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , ('admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +select results_eq ( + $$ select * from check_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') as (e text, r name) $$, + $$ values ('demo@tandem.blog', 'invoicer'::name) $$, + 'Should validate the cookie for the first user' +); + +select results_eq ( + $$ select * from check_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog') as (e text, r name) $$, + $$ values ('admin@tandem.blog', 'admin'::name) $$, + 'Should validate the cookie for the second user' +); + +select results_eq ( + $$ select * from check_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/admin@tandem.blog') as (e text, r name) $$, + $$ values ('', 'guest'::name) $$, + 'Should only match with the correct email' +); + +select results_eq ( + $$ select * from check_cookie('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/admin@tandem.blog') as (e text, r name) $$, + $$ values ('', 'guest'::name) $$, + 'Should only match with the correct cookie value' +); + +update "user" set cookie_expires_at = current_timestamp - interval '1 minute'; + +select results_eq ( + $$ select * from check_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') as (e text, r name) $$, + $$ values ('', 'guest'::name) $$, + 'Should not allow expired cookies' +); + +select results_eq ( + $$ select * from check_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog') as (e text, r name) $$, + $$ values ('', 'guest'::name) $$, + 'Should not allow expired cookied for the other user as well' +); + + +select * +from finish(); + +rollback; diff --git a/test/login.sql b/test/login.sql index 7130ae1..2da4423 100644 --- a/test/login.sql +++ b/test/login.sql @@ -5,47 +5,99 @@ reset client_min_messages; begin; -select plan(12); +select plan(20); -set search_path to numerus, auth, public; +set search_path to auth, numerus, public; select has_function('login'); -select function_lang_is('login', array ['email', 'text'], 'plpgsql'); -select function_returns('login', array ['email', 'text'], 'name'); -select is_definer('login', array ['email', 'text']); -select volatility_is('login', array ['email', 'text'], 'stable'); -select function_privs_are('login', array ['email', 'text'], 'guest', array ['EXECUTE']); -select function_privs_are('login', array ['email', 'text'], 'invoicer', array []::text[]); -select function_privs_are('login', array ['email', 'text'], 'admin', array []::text[]); -select function_privs_are('login', array ['email', 'text'], 'authenticator', array []::text[]); +select function_lang_is('login', array ['email', 'text', 'inet'], 'plpgsql'); +select function_returns('login', array ['email', 'text', 'inet'], 'text'); +select is_definer('login', array ['email', 'text', 'inet']); +select volatility_is('login', array ['email', 'text', 'inet'], 'volatile'); +select function_privs_are('login', array ['email', 'text', 'inet'], 'guest', array ['EXECUTE']); +select function_privs_are('login', array ['email', 'text', 'inet'], 'invoicer', array []::text[]); +select function_privs_are('login', array ['email', 'text', 'inet'], 'admin', array []::text[]); +select function_privs_are('login', array ['email', 'text', 'inet'], 'authenticator', array []::text[]); set client_min_messages to warning; truncate auth."user" cascade; +truncate auth.login_attempt cascade; reset client_min_messages; insert into auth."user" (email, name, password, role) -values ('info@tandem.blog', 'Perita', 'test', 'guest'); +values ('info@tandem.blog', 'Tandem', 'test', 'invoicer'); -select is( - login('info@tandem.blog'::email, 'test'), - 'guest'::name, - 'Should find the role with the correct email and password' +create temp table _login_test (result_num integer, cookie text not null); + +select lives_ok ( + $$ insert into _login_test select 1, split_part(login('info@tandem.blog', 'test', '::1'::inet), '/', 1) $$, + 'Should login with a correct user and password' ); -select throws_ok( - $$ select login('info@tandem.blog'::email, 'mah password') $$, - '28P01', - 'invalid user or password', +select isnt_empty ( + $$ select cookie from _login_test join "user" using (cookie) where email = 'info@tandem.blog' $$, + 'Should have returned the cookie that wrote to the user relation.' +); + +select results_eq ( + $$ select cookie_expires_at > current_timestamp from "user" where email = 'info@tandem.blog' $$, + $$ values (true) $$, + 'Should have set an expiry date in the future.' +); + +select isnt_empty ( + $$ select cookie from _login_test where cookie in (select split_part(login('info@tandem.blog', 'test', '192.168.0.1'::inet), '/', 1)) $$, + 'Should return the same cookie if not expired yet.' +); + +update "user" set cookie_expires_at = current_timestamp - interval '1 hour' where email = 'info@tandem.blog'; + +select lives_ok ( + $$ insert into _login_test select 2, split_part(login('info@tandem.blog', 'test', '::1'::inet), '/', 1) $$, + 'Should login with a correct user and password even with an expired cookie' +); + + +select results_eq( + $$ select count(distinct cookie)::integer from _login_test $$, + $$ values (2) $$, + 'Should have returned a new cookie' +); + +select isnt_empty ( + $$ select cookie from _login_test join "user" using (cookie) where email = 'info@tandem.blog' and result_num = 2 $$, + 'Should have updated the user’s cookie.' +); + +select results_eq( + $$ select cookie_expires_at > current_timestamp from "user" where email = 'info@tandem.blog' $$, + $$ values(true) $$, + 'Should have set an expiry date in the future, again.' +); + +select is( + login('info@tandem.blog'::email, 'mah password', '127.0.0.1'::inet), + ''::text, 'Should not find any role with an invalid password' ); -select throws_ok( - $$ select login('nope@tandem.blog'::email, 'test') $$, - '28P01', - 'invalid user or password', +select is( + login('nope@tandem.blog'::email, 'test'), + ''::text, 'Should not find any role with an invalid email' ); +select results_eq( + 'select user_name, ip_address, success, attempted_at from login_attempt order by attempt_id', + $$ values ('info@tandem.blog', '::1'::inet, true, current_timestamp) + , ('info@tandem.blog', '192.168.0.1'::inet, true, current_timestamp) + , ('info@tandem.blog', '::1'::inet, true, current_timestamp) + , ('info@tandem.blog', '127.0.0.1'::inet, false, current_timestamp) + , ('nope@tandem.blog', null, false, current_timestamp) + $$, + 'Should have recorded all login attempts.' +); + select * from finish(); diff --git a/test/login_attempt.sql b/test/login_attempt.sql new file mode 100644 index 0000000..b71c299 --- /dev/null +++ b/test/login_attempt.sql @@ -0,0 +1,50 @@ +-- Test login_attempt +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(29); + +set search_path to auth, public; + +select has_table('login_attempt'); +select has_pk('login_attempt'); +select table_privs_are('login_attempt', 'guest', array []::text[]); +select table_privs_are('login_attempt', 'invoicer', array []::text[]); +select table_privs_are('login_attempt', 'admin', array []::text[]); +select table_privs_are('login_attempt', 'authenticator', array []::text[]); + +select has_column('login_attempt', 'attempt_id'); +select col_is_pk('login_attempt', 'attempt_id'); +select col_type_is('login_attempt', 'attempt_id', 'bigint'); +select col_not_null('login_attempt', 'attempt_id'); +select col_has_default('login_attempt', 'attempt_id'); +select col_default_is('login_attempt', 'attempt_id', 'nextval(''login_attempt_attempt_id_seq''::regclass)'); + +select has_column('login_attempt', 'user_name'); +select col_type_is('login_attempt', 'user_name', 'text'); +select col_not_null('login_attempt', 'user_name'); +select col_hasnt_default('login_attempt', 'user_name'); + +select has_column('login_attempt', 'ip_address'); +select col_type_is('login_attempt', 'ip_address', 'inet'); +select col_is_null('login_attempt', 'ip_address'); +select col_hasnt_default('login_attempt', 'ip_address'); + +select has_column('login_attempt', 'success'); +select col_type_is('login_attempt', 'success', 'boolean'); +select col_not_null('login_attempt', 'success'); +select col_hasnt_default('login_attempt', 'success'); + +select has_column('login_attempt', 'attempted_at'); +select col_type_is('login_attempt', 'attempted_at', 'timestamp with time zone'); +select col_not_null('login_attempt', 'attempted_at'); +select col_has_default('login_attempt', 'attempted_at'); +select col_default_is('login_attempt', 'attempted_at', current_timestamp); + +select * +from finish(); + +rollback; diff --git a/test/logout.sql b/test/logout.sql new file mode 100644 index 0000000..a9edf63 --- /dev/null +++ b/test/logout.sql @@ -0,0 +1,71 @@ +-- Test logout +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(15); + +set search_path to auth, numerus, public; + +select has_function('logout'); +select function_lang_is('logout', array []::text[], 'sql'); +select function_returns('logout', array []::text[], 'void'); +select is_definer('logout', array []::text[]); +select volatility_is('logout', array []::text[], 'volatile'); +select function_privs_are('logout', array []::text[], 'guest', array []::text[]); +select function_privs_are('logout', array []::text[], 'invoicer', array ['EXECUTE']); +select function_privs_are('logout', array []::text[], 'admin', array ['EXECUTE']); +select function_privs_are('logout', array []::text[], 'authenticator', array []::text[]); + +set client_min_messages to warning; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (email, name, password, role, cookie, cookie_expires_at) +values ('info@tandem.blog', 'Tandem', 'test', 'invoicer', '8c23d4a8d777775f8fc507676a0d99d3dfa54b03b1b257c838', current_timestamp + interval '1 day') + , ('admin@tandem.blog', 'Admin', 'test', 'admin', '0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', current_timestamp + interval '2 day') +; + +prepare user_cookies as +select cookie, cookie_expires_at from "user" order by user_id +; + +select set_config('request.user', 'nothing', false); +select lives_ok( $$ select * from logout() $$, 'Can logout “nobody”' ); + +select results_eq( + 'user_cookies', + $$ values ('8c23d4a8d777775f8fc507676a0d99d3dfa54b03b1b257c838', current_timestamp + interval '1 day') + , ('0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', current_timestamp + interval '2 day') + $$, + 'Nothing changed' +); + +select set_config('request.user', 'info@tandem.blog', false); +select lives_ok( $$ select * from logout() $$, 'Can logout the first user' ); + +select results_eq( + 'user_cookies', + $$ values ('', '-infinity'::timestamptz) + , ('0169e5f668eec1e6749fd25388b057997358efa8dfd697961a'::text, current_timestamp + interval '2 day') + $$, + 'The first user logged out' +); + +select set_config('request.user', 'admin@tandem.blog', false); +select lives_ok( $$ select * from logout() $$, 'Can logout the second user' ); + +select results_eq( + 'user_cookies', + $$ values ('', '-infinity'::timestamptz) + , ('', '-infinity'::timestamptz) + $$, + 'The second user logged out' +); + +select * +from finish(); + +rollback; diff --git a/test/user.sql b/test/user.sql index ea371c4..ef5bb63 100644 --- a/test/user.sql +++ b/test/user.sql @@ -5,7 +5,7 @@ reset client_min_messages; begin; -select plan(34); +select plan(44); set search_path to auth, public; @@ -44,6 +44,18 @@ select col_type_is('user', 'role', 'name'); select col_not_null('user', 'role'); select col_hasnt_default('user', 'role'); +select has_column('user', 'cookie'); +select col_type_is('user', 'cookie', 'text'); +select col_not_null('user', 'cookie'); +select col_has_default('user', 'cookie'); +select col_default_is('user', 'cookie', ''); + +select has_column('user', 'cookie_expires_at'); +select col_type_is('user', 'cookie_expires_at', 'timestamp with time zone'); +select col_not_null('user', 'cookie_expires_at'); +select col_has_default('user', 'cookie_expires_at'); +select col_default_is('user', 'cookie_expires_at', '-infinity'::timestamp); + select has_column('user', 'created_at'); select col_type_is('user', 'created_at', 'timestamp with time zone'); select col_not_null('user', 'created_at'); diff --git a/verify/check_cookie.sql b/verify/check_cookie.sql new file mode 100644 index 0000000..f601bfd --- /dev/null +++ b/verify/check_cookie.sql @@ -0,0 +1,7 @@ +-- Verify numerus:check_cookie on pg + +begin; + +select has_function_privilege('numerus.check_cookie(text)', 'execute'); + +rollback; diff --git a/verify/login.sql b/verify/login.sql index 47a593a..e84145d 100644 --- a/verify/login.sql +++ b/verify/login.sql @@ -2,6 +2,6 @@ begin; -select has_function_privilege('numerus.login(numerus.email, text)', 'execute'); +select has_function_privilege('numerus.login(numerus.email, text, inet)', 'execute'); rollback; diff --git a/verify/login_attempt.sql b/verify/login_attempt.sql new file mode 100644 index 0000000..b5ff600 --- /dev/null +++ b/verify/login_attempt.sql @@ -0,0 +1,13 @@ +-- Verify numerus:login_attempt on pg + +begin; + +select attempt_id + , user_name + , ip_address + , success + , attempted_at +from auth.login_attempt +where false; + +rollback; diff --git a/verify/logout.sql b/verify/logout.sql new file mode 100644 index 0000000..bb34e28 --- /dev/null +++ b/verify/logout.sql @@ -0,0 +1,7 @@ +-- Verify numerus:logout on pg + +begin; + +select has_function_privilege('numerus.logout()', 'execute'); + +rollback; diff --git a/verify/user.sql b/verify/user.sql index 039166f..52181ca 100644 --- a/verify/user.sql +++ b/verify/user.sql @@ -8,6 +8,8 @@ select , name , password , role +, cookie +, cookie_expires_at , created_at from auth."user" where false; diff --git a/web/template/index.html b/web/template/index.html index d6f337c..48056f4 100644 --- a/web/template/index.html +++ b/web/template/index.html @@ -7,15 +7,9 @@

Numerus

-

Login

-
- - - - - - - +

Welcome

+ +
diff --git a/web/template/login.html b/web/template/login.html new file mode 100644 index 0000000..06100f1 --- /dev/null +++ b/web/template/login.html @@ -0,0 +1,24 @@ + + + + + + Login — Numerus + + +

Numerus

+

Login

+ {{ if .LoginError }} +

{{ .LoginError }}

+ {{ end }} +
+ + + + + + + +
+ +