Compare commits

..

No commits in common. "c369364642ffd44d3f7992d9c81f96ddfe0492a5" and "6d48aa6630459b438f32a32ba5cc8d4e2cbfb545" have entirely different histories.

59 changed files with 205 additions and 1181 deletions

View File

@ -9,19 +9,21 @@ import (
"syscall"
"time"
numerus "dev.tandem.ws/tandem/numerus/pkg"
"github.com/jackc/pgx/v4/pgxpool"
"dev.tandem.ws/tandem/numerus/pkg/router"
)
func main() {
db, err := numerus.NewDatabase(context.Background(), os.Getenv("NUMERUS_DATABASE_URL"))
dbpool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
defer dbpool.Close()
srv := http.Server{
Addr: ":8080",
Handler: numerus.NewRouter(db),
Handler: router.NewRouter(dbpool),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,

11
debian/control vendored
View File

@ -46,14 +46,3 @@ Description: Simple invoicing and accounting web application
contractors working in Spain.
.
This is the Sqitch migration package.
Package: numerus-demo
Architecture: all
Depends:
${misc:Depends},
sqitch
Description: Simple invoicing and accounting web application
A simple web application to keep invoice and accouting records, intended for
contractors working in Spain.
.
This is the demo SQL script.

103
debian/copyright vendored
View File

@ -8,106 +8,3 @@ Files:
Copyright:
2023 jordi fita mas
License: AGPL-3.0-only
Files:
web/static/fonts/*
Copyright:
2020 Philipp Nurullin
2020 Konstantin Bulenkov
2020 The JetBrains Mono Project Authors
License: OFL-1.1
License: OFL-1.1
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
.
.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
.
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
.
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
.
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1 +0,0 @@
demo/demo.sql usr/share/numerus

View File

@ -1,10 +0,0 @@
begin;
set search_path to auth, numerus, public;
insert into auth."user" (email, name, password, role)
values ('demo@numerus', 'Demo User', 'demo', 'invoicer')
, ('admin@numerus', 'Demo Admin', 'admin', 'admin')
;
commit;

View File

@ -1,39 +0,0 @@
-- 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;

View File

@ -21,8 +21,6 @@ language plpgsql;
comment on function encrypt_password() is
'Encrypts and salts the input password with the blowfish encryption algorithm';
revoke execute on function encrypt_password() from public;
create trigger encrypt_password
before insert or update
on "user"

View File

@ -20,8 +20,6 @@ language plpgsql;
comment on function ensure_role_exists() is
'Makes sure that a role given to a user is a valid, existing role in the cluster.';
revoke execute on function ensure_role_exists() from public;
create trigger ensure_role_exists
after insert or update
on "user"

View File

@ -1,8 +1,8 @@
-- Deploy numerus:extension_pgcrypto to pg
-- requires: schema_auth
-- requires: schema_public
begin;
create extension if not exists pgcrypto with schema auth;
create extension if not exists pgcrypto;
commit;

24
deploy/find_user_role.sql Normal file
View File

@ -0,0 +1,24 @@
-- Deploy numerus:find_user_role to pg
-- requires: schema_auth
-- requires: user
-- requires: email
begin;
set search_path to auth, numerus, public;
create or replace function find_user_role(email email, password text) returns name
as
$$
select role
from auth."user"
where "user".email = find_user_role.email
and "user".password = crypt(find_user_role.password, "user".password);
$$
language sql
stable;
comment on function find_user_role(email, text) is
'Return the database role assigned to the user with the given email and password';
commit;

View File

@ -1,64 +1,33 @@
-- Deploy numerus:login to pg
-- requires: roles
-- requires: schema_numerus
-- requires: schema_auth
-- requires: extension_pgcrypto
-- requires: email
-- requires: user
-- requires: login_attempt
-- requires: find_user_role
begin;
set search_path to numerus, auth;
create or replace function login(email email, password text, ip_address inet default null) returns text as
create or replace function login(email email, password text) returns name as
$$
declare
user_cookie text;
role name;
begin
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 '';
select auth.find_user_role(email, password) into role;
if role is null then
raise invalid_password using message = 'invalid user or password';
end if;
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;
return role;
end;
$$
language plpgsql
security definer
set search_path = auth, numerus, pg_temp;
stable
security definer;
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.';
comment on function login(email, text) is
'Checks that the email and password pair is valid and returns the users databasse role.';
revoke execute on function login(email, text, inet) from public;
grant execute on function login(email, text, inet) to guest;
grant execute on function login(email, text) to guest;
commit;

View File

@ -1,16 +0,0 @@
-- 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;

View File

@ -1,27 +0,0 @@
-- 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;

View File

@ -6,6 +6,8 @@ begin;
revoke create on schema public from public;
revoke usage on schema public from public;
alter default privileges revoke execute on functions from public;
grant usage on schema public to guest;
grant usage on schema public to invoicer;
grant usage on schema public to admin;

View File

@ -13,8 +13,6 @@ 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
);

View File

@ -1,79 +0,0 @@
package pkg
import (
"context"
"log"
"net/http"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
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)
}
config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(context.Background(), "SET search_path TO numerus, public")
return err
}
config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
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
}
config.AfterRelease = func(conn *pgx.Conn) bool {
if _, err := conn.Exec(context.Background(), "RESET ROLE"); err != nil {
log.Printf("ERROR - Failed to reset role: %v", err)
return false
}
return true
}
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)
}
}

View File

@ -1,4 +1,4 @@
package pkg
package logger
import (
"log"

View File

@ -1,117 +0,0 @@
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)
}

View File

@ -1,29 +0,0 @@
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);
});
}

View File

@ -1,41 +0,0 @@
package pkg
import (
"html/template"
"net/http"
)
func NewRouter(db *Db) http.Handler {
router := http.NewServeMux()
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
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 {
panic(err)
}
err = t.Execute(w, nil)
if err != nil {
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
}

52
pkg/router/router.go Normal file
View File

@ -0,0 +1,52 @@
package router
import (
"context"
"html/template"
"log"
"net/http"
"dev.tandem.ws/tandem/numerus/pkg/logger"
"github.com/jackc/pgx/v4/pgxpool"
)
func NewRouter(db *pgxpool.Pool) 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
if _, err := db.Exec(context.Background(), "select set_config('search_path', 'numerus, public', false)"); err != nil {
log.Printf("ERROR - %s", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err := db.QueryRow(context.Background(), "select login($1, $2)", email, password).Scan(&role)
if err != nil {
log.Printf("ERROR - %s", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(role))
})
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
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
}
err = t.Execute(w, nil)
if err != nil {
log.Printf("ERROR - %s", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
var handler http.Handler = router
handler = logger.Logger(handler)
return handler
}

View File

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

View File

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

View File

@ -2,6 +2,6 @@
begin;
drop function if exists numerus.login(numerus.email, text, inet);
drop function if exists numerus.login(numerus.email, text);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:login_attempt from pg
begin;
drop table if exists auth.login_attempt;
commit;

View File

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

View File

@ -10,9 +10,7 @@ extension_citext [schema_public] 2023-01-12T23:03:33Z jordi fita i mas <jfita@in
email [schema_numerus extension_citext] 2023-01-12T23:09:59Z jordi fita i mas <jfita@infoblitz.com> # Add email domain
user [roles schema_auth email] 2023-01-12T23:44:03Z jordi fita i mas <jfita@infoblitz.com> # Create user table
ensure_role_exists [schema_auth user] 2023-01-12T23:57:59Z jordi fita i mas <jfita@infoblitz.com> # Add trigger to ensure the users role exists
extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita i mas <jfita@infoblitz.com> # Add pgcrypto extension
extension_pgcrypto [schema_public] 2023-01-13T00:11:50Z jordi fita i mas <jfita@infoblitz.com> # Add pgcrypto extension
encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita i mas <jfita@infoblitz.com> # Add trigger to encrypt users password
login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita i mas <jfita@infoblitz.com> # 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 <jfita@infoblitz.com> # Add function to login
check_cookie [schema_auth user] 2023-01-17T17:48:49Z jordi fita i mas <jfita@infoblitz.com> # Add function to check if a user cookie is valid
logout [schema_auth user] 2023-01-17T19:10:21Z jordi fita i mas <jfita@infoblitz.com> # Add function to logout
find_user_role [schema_auth user email] 2023-01-13T00:22:34Z jordi fita i mas <jfita@infoblitz.com> # Add function to find a users role given its email and password
login [roles schema_numerus email user find_user_role] 2023-01-13T00:32:32Z jordi fita i mas <jfita@infoblitz.com> # Add function to login

View File

@ -1,73 +0,0 @@
-- 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;

51
test/find_user_role.sql Normal file
View File

@ -0,0 +1,51 @@
-- Test find_user_role
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to auth, numerus, public;
select has_function('find_user_role');
select function_lang_is('find_user_role', array ['email', 'text'], 'sql');
select function_returns('find_user_role', array ['email', 'text'], 'name');
select isnt_definer('find_user_role', array ['email', 'text']);
select volatility_is('find_user_role', array ['email', 'text'], 'stable');
select function_privs_are('find_user_role', array ['email', 'text'], 'guest', array []::text[]);
select function_privs_are('find_user_role', array ['email', 'text'], 'invoicer', array []::text[]);
select function_privs_are('find_user_role', array ['email', 'text'], 'admin', array []::text[]);
select function_privs_are('find_user_role', array ['email', 'text'], 'authenticator', array []::text[]);
set client_min_messages to warning;
truncate "user" cascade;
reset client_min_messages;
insert into "user" (email, name, password, role)
values ('info@tandem.blog', 'Perita', 'test', 'guest');
select is(
find_user_role('info@tandem.blog', 'test'),
'guest'::name,
'Should find the role with the correct email and password'
);
select is(
find_user_role('info@tandem.blog', 'mah password'),
NULL::name,
'Should not find any role with an invalid password'
);
select is(
find_user_role('nope@tandem.blog', 'test'),
NULL::name,
'Should not find any role with an invalid email'
);
select *
from finish();
rollback;

View File

@ -5,99 +5,47 @@ reset client_min_messages;
begin;
select plan(20);
select plan(12);
set search_path to auth, numerus, public;
set search_path to numerus, public;
select has_function('login');
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[]);
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[]);
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', 'Tandem', 'test', 'invoicer');
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 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 users 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.'
);
values ('info@tandem.blog', 'Perita', 'test', 'guest');
select is(
login('info@tandem.blog'::email, 'mah password', '127.0.0.1'::inet),
''::text,
login('info@tandem.blog'::email, 'test'),
'guest'::name,
'Should find the role with the correct email and password'
);
select throws_ok(
$$ select login('info@tandem.blog'::email, 'mah password') $$,
'28P01',
'invalid user or password',
'Should not find any role with an invalid password'
);
select is(
login('nope@tandem.blog'::email, 'test'),
''::text,
select throws_ok(
$$ select login('nope@tandem.blog'::email, 'test') $$,
'28P01',
'invalid user or password',
'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();

View File

@ -1,50 +0,0 @@
-- 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;

View File

@ -1,71 +0,0 @@
-- 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;

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(44);
select plan(34);
set search_path to auth, public;
@ -44,18 +44,6 @@ 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');

View File

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

View File

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

View File

@ -2,6 +2,6 @@
begin;
select has_function_privilege('numerus.login(numerus.email, text, inet)', 'execute');
select has_function_privilege('numerus.login(numerus.email, text)', 'execute');
rollback;

View File

@ -1,13 +0,0 @@
-- Verify numerus:login_attempt on pg
begin;
select attempt_id
, user_name
, ip_address
, success
, attempted_at
from auth.login_attempt
where false;
rollback;

View File

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

View File

@ -8,8 +8,6 @@ select
, name
, password
, role
, cookie
, cookie_expires_at
, created_at
from auth."user"
where false;

View File

@ -1,266 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
*
* SPDX-License-Identifier: GPL-3.0-only
*/
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 100;
font-display: swap;
src: local('JetBrains Mono Thin'), url('./fonts/JetBrainsMono-ThinItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 200;
font-display: swap;
src: local('JetBrains Mono ExtraLight'), url('./fonts/JetBrainsMono-ExtraLightItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 300;
font-display: swap;
src: local('JetBrains Mono Light'), url('./fonts/JetBrainsMono-LightItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: local('JetBrains Mono'), url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 500;
font-display: swap;
src: local('JetBrains Mono Medium'), url('./fonts/JetBrainsMono-MediumItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 600;
font-display: swap;
src: local('JetBrains Mono SemiBoldItalic'), url('./fonts/JetBrainsMono-SemiBoldItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 700;
font-display: swap;
src: local('JetBrains Mono Bold'), url('./fonts/JetBrainsMono-BoldItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 800;
font-display: swap;
src: local('JetBrains Mono ExtraBold'), url('./fonts/JetBrainsMono-ExtraBoldItalic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100;
font-display: swap;
src: local('JetBrains Mono Thin'), url('./fonts/JetBrainsMono-Thin.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 200;
font-display: swap;
src: local('JetBrains Mono ExtraLight'), url('./fonts/JetBrainsMono-ExtraLight.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('JetBrains Mono Light'), url('./fonts/JetBrainsMono-Light.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local('JetBrains Mono'), url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: local('JetBrains Mono Medium'), url('./fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: local('JetBrains Mono SemiBold'), url('./fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src:
local('JetBrains Mono Bold'), url('./fonts/JetBrainsMono-Bold.woff2') format('woff2'); }
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 800;
font-display: swap;
src: local('JetBrains Mono ExtraBold'), url('./fonts/JetBrainsMono-ExtraBold.woff2') format('woff2');
}
:root {
--numerus--color--black: #3f3b37;
--numerus--color--dark-gray: #8a8885;
--numerus--color--light-gray: #e1dbd6;
--numerus--color--white: #ffffff;
--numerus--color--yellow: #ffd200;
--numerus--color--red: #ff7a53;
--numerus--color--green: #5ae487;
--numerus--color--blue: #55bfff;
--numerus--color--hay: #ffe673;
--numerus--text-color: var(--numerus--color--black);
--numerus--background-color: var(--numerus--color-white);
--numerus--font-family: 'JetBrains Mono';
--numerus--header--background-color: #ede9e5;
}
html, body {
height: 100%;
}
html {
font-family: var(--numerus--font-family), monospace;
}
body {
color: var(--numerus--text-color);
background-color: var(--numerus--background-color);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
input[type="submit"], button {
background-color: var(--numerus--color--white);
border: 2px solid var(--numerus--color--black);
text-transform: uppercase;
}
input[type="submit"]:hover, button:hover {
background-color: var(--numerus--color--light-gray);
}
input[type="submit"]:active, button:active {
background-color: var(--numerus--color--black);
border-color: var(--numerus--color--white);
text-color: var(--numerus--color--white);
}
.web {
margin: 5.3125rem 2.5rem;
background-color: var(--numerus--header--background-color);
}
.web h1 {
margin-bottom: 1.875em;
}
#login {
background-color: var(--numerus--color--hay);
padding: 1.5625em;
}
#login h2 {
margin-bottom: 1em;
}
div[role="alert"].error {
padding: 1.3125em;
background-color: var(--numerus--color--red);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--numerus--header--background-color);
padding: .625rem 1.875rem;
}
nav {
position: relative;
}
nav > button {
width: 4.375rem;
height: 4.375rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
border: none;
}
nav button {
cursor: pointer;
}
nav ul {
list-style: none;
padding: none;
position: absolute;
right: -1.875em;
top: calc(100% + .625rem);
}
nav ul button {
border: 0;
width: 31.25rem;
height: 5.25rem;
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -4,21 +4,18 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body>
<header>
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
<nav role="navigation">
<button aria-haspopup="true">
<svg role="image" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="50" height="50"><path fill="none" d="M0 0h24v24H0z"/><path d="M9.342 18.782l-1.931-.518.787-2.939a10.988 10.988 0 0 1-3.237-1.872l-2.153 2.154-1.415-1.415 2.154-2.153a10.957 10.957 0 0 1-2.371-5.07l1.968-.359C3.903 10.812 7.579 14 12 14c4.42 0 8.097-3.188 8.856-7.39l1.968.358a10.957 10.957 0 0 1-2.37 5.071l2.153 2.153-1.415 1.415-2.153-2.154a10.988 10.988 0 0 1-3.237 1.872l.787 2.94-1.931.517-.788-2.94a11.072 11.072 0 0 1-3.74 0l-.788 2.94z"/></svg></button>
<ul>
<li><form method="POST" action="/logout"><button type="submit">Logout</button></form></li>
</ul>
</nav>
</header>
<main>
<h2>Welcome</h2>
</main>
<h1>Numerus</h1>
<h2>Login</h2>
<form method="POST" action="/login">
<label for="user_email">Email</label>
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none">
<label for="user_password">Password</label>
<input id="user_password" type="password" required name="password" autocomplete="current-password">
<button type="submit">Login</button>
</form>
</body>
</html>

View File

@ -1,31 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login — Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body class="web">
<h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1>
{{ if .LoginError }}
<div class="error" role="alert">
<p>{{ .LoginError }}</p>
</div>
{{ end }}
<section id="login">
<h2>Login</h2>
<form method="POST" action="/login">
<label for="user_email">Email</label>
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}">
<label for="user_password">Password</label>
<input id="user_password" type="password" required name="password" autocomplete="current-password" value="{{ .Password }}">
<button type="submit">Login</button>
</form>
</section>
</body>
</html>