Compare commits
8 Commits
6d48aa6630
...
c369364642
Author | SHA1 | Date | |
---|---|---|---|
c369364642 | |||
d434d040af | |||
f1bf1f896d | |||
ab6c0079c9 | |||
97ac586a3b | |||
8fd22672c7 | |||
45439c8559 | |||
989cdd7da7 |
@ -9,21 +9,19 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
|
||||
"dev.tandem.ws/tandem/numerus/pkg/router"
|
||||
numerus "dev.tandem.ws/tandem/numerus/pkg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbpool, err := pgxpool.Connect(context.Background(), os.Getenv("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: router.NewRouter(dbpool),
|
||||
Handler: numerus.NewRouter(db),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
|
11
debian/control
vendored
11
debian/control
vendored
@ -46,3 +46,14 @@ 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
103
debian/copyright
vendored
@ -8,3 +8,106 @@ 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.
|
||||
|
1
debian/numerus-demo.install
vendored
Normal file
1
debian/numerus-demo.install
vendored
Normal file
@ -0,0 +1 @@
|
||||
demo/demo.sql usr/share/numerus
|
10
demo/demo.sql
Normal file
10
demo/demo.sql
Normal file
@ -0,0 +1,10 @@
|
||||
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;
|
39
deploy/check_cookie.sql
Normal file
39
deploy/check_cookie.sql
Normal file
@ -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;
|
@ -21,6 +21,8 @@ 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"
|
||||
|
@ -20,6 +20,8 @@ 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"
|
||||
|
@ -1,8 +1,8 @@
|
||||
-- Deploy numerus:extension_pgcrypto to pg
|
||||
-- requires: schema_public
|
||||
-- requires: schema_auth
|
||||
|
||||
begin;
|
||||
|
||||
create extension if not exists pgcrypto;
|
||||
create extension if not exists pgcrypto with schema auth;
|
||||
|
||||
commit;
|
||||
|
@ -1,24 +0,0 @@
|
||||
-- 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;
|
@ -1,33 +1,64 @@
|
||||
-- Deploy numerus:login to pg
|
||||
-- requires: roles
|
||||
-- requires: schema_numerus
|
||||
-- requires: schema_auth
|
||||
-- requires: extension_pgcrypto
|
||||
-- requires: email
|
||||
-- requires: user
|
||||
-- requires: find_user_role
|
||||
-- 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
|
||||
role name;
|
||||
user_cookie text;
|
||||
begin
|
||||
select auth.find_user_role(email, password) into role;
|
||||
if 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 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;
|
||||
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.';
|
||||
|
||||
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;
|
||||
|
16
deploy/login_attempt.sql
Normal file
16
deploy/login_attempt.sql
Normal file
@ -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;
|
27
deploy/logout.sql
Normal file
27
deploy/logout.sql
Normal file
@ -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;
|
@ -6,8 +6,6 @@ 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;
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
|
79
pkg/db.go
Normal file
79
pkg/db.go
Normal file
@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package logger
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"log"
|
117
pkg/login.go
Normal file
117
pkg/login.go
Normal file
@ -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)
|
||||
}
|
29
pkg/recover.go
Normal file
29
pkg/recover.go
Normal file
@ -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);
|
||||
});
|
||||
}
|
41
pkg/router.go
Normal file
41
pkg/router.go
Normal file
@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
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
|
||||
}
|
7
revert/check_cookie.sql
Normal file
7
revert/check_cookie.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Revert numerus:check_cookie from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.check_cookie(text);
|
||||
|
||||
commit;
|
@ -1,7 +0,0 @@
|
||||
-- Revert numerus:find_user_role from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists auth.find_user_role(numerus.email, text);
|
||||
|
||||
commit;
|
@ -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;
|
||||
|
7
revert/login_attempt.sql
Normal file
7
revert/login_attempt.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Revert numerus:login_attempt from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop table if exists auth.login_attempt;
|
||||
|
||||
commit;
|
7
revert/logout.sql
Normal file
7
revert/logout.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Revert numerus:logout from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.logout();
|
||||
|
||||
commit;
|
@ -10,7 +10,9 @@ 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 user’s role exists
|
||||
extension_pgcrypto [schema_public] 2023-01-13T00:11:50Z jordi fita i mas <jfita@infoblitz.com> # Add pgcrypto extension
|
||||
extension_pgcrypto [schema_auth] 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 user’s password
|
||||
find_user_role [schema_auth user email] 2023-01-13T00:22:34Z jordi fita i mas <jfita@infoblitz.com> # Add function to find a user’s 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
|
||||
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
|
||||
|
73
test/check_cookie.sql
Normal file
73
test/check_cookie.sql
Normal file
@ -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;
|
@ -1,51 +0,0 @@
|
||||
-- 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;
|
@ -5,47 +5,99 @@ reset client_min_messages;
|
||||
|
||||
begin;
|
||||
|
||||
select plan(12);
|
||||
select plan(20);
|
||||
|
||||
set search_path to numerus, 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();
|
||||
|
50
test/login_attempt.sql
Normal file
50
test/login_attempt.sql
Normal file
@ -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;
|
71
test/logout.sql
Normal file
71
test/logout.sql
Normal file
@ -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;
|
@ -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');
|
||||
|
7
verify/check_cookie.sql
Normal file
7
verify/check_cookie.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Verify numerus:check_cookie on pg
|
||||
|
||||
begin;
|
||||
|
||||
select has_function_privilege('numerus.check_cookie(text)', 'execute');
|
||||
|
||||
rollback;
|
@ -1,7 +0,0 @@
|
||||
-- Verify numerus:find_user_role on pg
|
||||
|
||||
begin;
|
||||
|
||||
select has_function_privilege('auth.find_user_role(numerus.email, text)', 'execute');
|
||||
|
||||
rollback;
|
@ -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;
|
||||
|
13
verify/login_attempt.sql
Normal file
13
verify/login_attempt.sql
Normal file
@ -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;
|
7
verify/logout.sql
Normal file
7
verify/logout.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Verify numerus:logout on pg
|
||||
|
||||
begin;
|
||||
|
||||
select has_function_privilege('numerus.logout()', 'execute');
|
||||
|
||||
rollback;
|
@ -8,6 +8,8 @@ select
|
||||
, name
|
||||
, password
|
||||
, role
|
||||
, cookie
|
||||
, cookie_expires_at
|
||||
, created_at
|
||||
from auth."user"
|
||||
where false;
|
||||
|
BIN
web/static/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-BoldItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-ExtraBold.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-ExtraBoldItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-ExtraLight.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-ExtraLightItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-Italic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-Italic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-Light.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-Light.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-LightItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-LightItalic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-Medium.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-Medium.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-MediumItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-SemiBold.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-SemiBold.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-SemiBoldItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-Thin.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-Thin.woff2
Normal file
Binary file not shown.
BIN
web/static/fonts/JetBrainsMono-ThinItalic.woff2
Normal file
BIN
web/static/fonts/JetBrainsMono-ThinItalic.woff2
Normal file
Binary file not shown.
266
web/static/numerus.css
Normal file
266
web/static/numerus.css
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
1
web/static/numerus.svg
Normal file
1
web/static/numerus.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 6.0 KiB |
@ -4,18 +4,21 @@
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
31
web/template/login.html
Normal file
31
web/template/login.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!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>
|
Loading…
x
Reference in New Issue
Block a user