Compare commits
8 Commits
6d48aa6630
...
c369364642
Author | SHA1 | Date |
---|---|---|
jordi fita mas | c369364642 | |
jordi fita mas | d434d040af | |
jordi fita mas | f1bf1f896d | |
jordi fita mas | ab6c0079c9 | |
jordi fita mas | 97ac586a3b | |
jordi fita mas | 8fd22672c7 | |
jordi fita mas | 45439c8559 | |
jordi fita mas | 989cdd7da7 |
|
@ -9,21 +9,19 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v4/pgxpool"
|
numerus "dev.tandem.ws/tandem/numerus/pkg"
|
||||||
|
|
||||||
"dev.tandem.ws/tandem/numerus/pkg/router"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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 {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer dbpool.Close()
|
defer db.Close()
|
||||||
|
|
||||||
srv := http.Server{
|
srv := http.Server{
|
||||||
Addr: ":8080",
|
Addr: ":8080",
|
||||||
Handler: router.NewRouter(dbpool),
|
Handler: numerus.NewRouter(db),
|
||||||
ReadTimeout: 5 * time.Second,
|
ReadTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
IdleTimeout: 2 * time.Minute,
|
IdleTimeout: 2 * time.Minute,
|
||||||
|
|
|
@ -46,3 +46,14 @@ Description: Simple invoicing and accounting web application
|
||||||
contractors working in Spain.
|
contractors working in Spain.
|
||||||
.
|
.
|
||||||
This is the Sqitch migration package.
|
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.
|
||||||
|
|
|
@ -8,3 +8,106 @@ Files:
|
||||||
Copyright:
|
Copyright:
|
||||||
2023 jordi fita mas
|
2023 jordi fita mas
|
||||||
License: AGPL-3.0-only
|
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.
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
demo/demo.sql usr/share/numerus
|
|
@ -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;
|
|
@ -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
|
comment on function encrypt_password() is
|
||||||
'Encrypts and salts the input password with the blowfish encryption algorithm';
|
'Encrypts and salts the input password with the blowfish encryption algorithm';
|
||||||
|
|
||||||
|
revoke execute on function encrypt_password() from public;
|
||||||
|
|
||||||
create trigger encrypt_password
|
create trigger encrypt_password
|
||||||
before insert or update
|
before insert or update
|
||||||
on "user"
|
on "user"
|
||||||
|
|
|
@ -20,6 +20,8 @@ language plpgsql;
|
||||||
comment on function ensure_role_exists() is
|
comment on function ensure_role_exists() is
|
||||||
'Makes sure that a role given to a user is a valid, existing role in the cluster.';
|
'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
|
create trigger ensure_role_exists
|
||||||
after insert or update
|
after insert or update
|
||||||
on "user"
|
on "user"
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
-- Deploy numerus:extension_pgcrypto to pg
|
-- Deploy numerus:extension_pgcrypto to pg
|
||||||
-- requires: schema_public
|
-- requires: schema_auth
|
||||||
|
|
||||||
begin;
|
begin;
|
||||||
|
|
||||||
create extension if not exists pgcrypto;
|
create extension if not exists pgcrypto with schema auth;
|
||||||
|
|
||||||
commit;
|
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
|
-- Deploy numerus:login to pg
|
||||||
-- requires: roles
|
-- requires: roles
|
||||||
-- requires: schema_numerus
|
-- requires: schema_numerus
|
||||||
|
-- requires: schema_auth
|
||||||
|
-- requires: extension_pgcrypto
|
||||||
-- requires: email
|
-- requires: email
|
||||||
-- requires: user
|
-- requires: user
|
||||||
-- requires: find_user_role
|
-- requires: login_attempt
|
||||||
|
|
||||||
begin;
|
begin;
|
||||||
|
|
||||||
set search_path to numerus, auth;
|
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
|
declare
|
||||||
role name;
|
user_cookie text;
|
||||||
begin
|
begin
|
||||||
select auth.find_user_role(email, password) into role;
|
if not exists (
|
||||||
if role is null then
|
select *
|
||||||
raise invalid_password using message = 'invalid user or password';
|
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;
|
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;
|
end;
|
||||||
$$
|
$$
|
||||||
language plpgsql
|
language plpgsql
|
||||||
stable
|
security definer
|
||||||
security definer;
|
set search_path = auth, numerus, pg_temp;
|
||||||
|
|
||||||
comment on function login(email, text) is
|
comment on function login(email, text, inet) is
|
||||||
'Checks that the email and password pair is valid and returns the user’s databasse role.';
|
'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;
|
commit;
|
||||||
|
|
|
@ -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;
|
|
@ -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 create on schema public from public;
|
||||||
revoke usage 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 guest;
|
||||||
grant usage on schema public to invoicer;
|
grant usage on schema public to invoicer;
|
||||||
grant usage on schema public to admin;
|
grant usage on schema public to admin;
|
||||||
|
|
|
@ -13,6 +13,8 @@ create table "user" (
|
||||||
name text not null,
|
name text not null,
|
||||||
password text not null check (length(password) < 512),
|
password text not null check (length(password) < 512),
|
||||||
role name not null check (length(role) < 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
|
created_at timestamptz not null default current_timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
import (
|
||||||
"log"
|
"log"
|
|
@ -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)
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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;
|
begin;
|
||||||
|
|
||||||
drop function if exists numerus.login(numerus.email, text);
|
drop function if exists numerus.login(numerus.email, text, inet);
|
||||||
|
|
||||||
commit;
|
commit;
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Revert numerus:login_attempt from pg
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
drop table if exists auth.login_attempt;
|
||||||
|
|
||||||
|
commit;
|
|
@ -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
|
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
|
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
|
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
|
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_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 email user find_user_role] 2023-01-13T00:32:32Z jordi fita i mas <jfita@infoblitz.com> # Add function to login
|
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
|
||||||
|
|
|
@ -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;
|
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 has_function('login');
|
||||||
select function_lang_is('login', array ['email', 'text'], 'plpgsql');
|
select function_lang_is('login', array ['email', 'text', 'inet'], 'plpgsql');
|
||||||
select function_returns('login', array ['email', 'text'], 'name');
|
select function_returns('login', array ['email', 'text', 'inet'], 'text');
|
||||||
select is_definer('login', array ['email', 'text']);
|
select is_definer('login', array ['email', 'text', 'inet']);
|
||||||
select volatility_is('login', array ['email', 'text'], 'stable');
|
select volatility_is('login', array ['email', 'text', 'inet'], 'volatile');
|
||||||
select function_privs_are('login', array ['email', 'text'], 'guest', array ['EXECUTE']);
|
select function_privs_are('login', array ['email', 'text', 'inet'], 'guest', array ['EXECUTE']);
|
||||||
select function_privs_are('login', array ['email', 'text'], 'invoicer', array []::text[]);
|
select function_privs_are('login', array ['email', 'text', 'inet'], 'invoicer', array []::text[]);
|
||||||
select function_privs_are('login', array ['email', 'text'], 'admin', array []::text[]);
|
select function_privs_are('login', array ['email', 'text', 'inet'], 'admin', array []::text[]);
|
||||||
select function_privs_are('login', array ['email', 'text'], 'authenticator', array []::text[]);
|
select function_privs_are('login', array ['email', 'text', 'inet'], 'authenticator', array []::text[]);
|
||||||
|
|
||||||
set client_min_messages to warning;
|
set client_min_messages to warning;
|
||||||
truncate auth."user" cascade;
|
truncate auth."user" cascade;
|
||||||
|
truncate auth.login_attempt cascade;
|
||||||
reset client_min_messages;
|
reset client_min_messages;
|
||||||
|
|
||||||
insert into auth."user" (email, name, password, role)
|
insert into auth."user" (email, name, password, role)
|
||||||
values ('info@tandem.blog', 'Perita', 'test', 'guest');
|
values ('info@tandem.blog', 'Tandem', 'test', 'invoicer');
|
||||||
|
|
||||||
select is(
|
create temp table _login_test (result_num integer, cookie text not null);
|
||||||
login('info@tandem.blog'::email, 'test'),
|
|
||||||
'guest'::name,
|
select lives_ok (
|
||||||
'Should find the role with the correct email and password'
|
$$ 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 isnt_empty (
|
||||||
$$ select login('info@tandem.blog'::email, 'mah password') $$,
|
$$ select cookie from _login_test join "user" using (cookie) where email = 'info@tandem.blog' $$,
|
||||||
'28P01',
|
'Should have returned the cookie that wrote to the user relation.'
|
||||||
'invalid user or password',
|
);
|
||||||
|
|
||||||
|
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'
|
'Should not find any role with an invalid password'
|
||||||
);
|
);
|
||||||
|
|
||||||
select throws_ok(
|
select is(
|
||||||
$$ select login('nope@tandem.blog'::email, 'test') $$,
|
login('nope@tandem.blog'::email, 'test'),
|
||||||
'28P01',
|
''::text,
|
||||||
'invalid user or password',
|
|
||||||
'Should not find any role with an invalid email'
|
'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 *
|
select *
|
||||||
from finish();
|
from finish();
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
begin;
|
||||||
|
|
||||||
select plan(34);
|
select plan(44);
|
||||||
|
|
||||||
set search_path to auth, public;
|
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_not_null('user', 'role');
|
||||||
select col_hasnt_default('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 has_column('user', 'created_at');
|
||||||
select col_type_is('user', 'created_at', 'timestamp with time zone');
|
select col_type_is('user', 'created_at', 'timestamp with time zone');
|
||||||
select col_not_null('user', 'created_at');
|
select col_not_null('user', 'created_at');
|
||||||
|
|
|
@ -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;
|
begin;
|
||||||
|
|
||||||
select has_function_privilege('numerus.login(numerus.email, text)', 'execute');
|
select has_function_privilege('numerus.login(numerus.email, text, inet)', 'execute');
|
||||||
|
|
||||||
rollback;
|
rollback;
|
||||||
|
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Verify numerus:logout on pg
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
select has_function_privilege('numerus.logout()', 'execute');
|
||||||
|
|
||||||
|
rollback;
|
|
@ -8,6 +8,8 @@ select
|
||||||
, name
|
, name
|
||||||
, password
|
, password
|
||||||
, role
|
, role
|
||||||
|
, cookie
|
||||||
|
, cookie_expires_at
|
||||||
, created_at
|
, created_at
|
||||||
from auth."user"
|
from auth."user"
|
||||||
where false;
|
where false;
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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;
|
||||||
|
}
|
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 charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Numerus</title>
|
<title>Numerus</title>
|
||||||
|
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Numerus</h1>
|
<header>
|
||||||
<h2>Login</h2>
|
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
|
||||||
<form method="POST" action="/login">
|
<nav role="navigation">
|
||||||
<label for="user_email">Email</label>
|
<button aria-haspopup="true">
|
||||||
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none">
|
<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>
|
||||||
<label for="user_password">Password</label>
|
<li><form method="POST" action="/logout"><button type="submit">Logout</button></form></li>
|
||||||
<input id="user_password" type="password" required name="password" autocomplete="current-password">
|
</ul>
|
||||||
|
</nav>
|
||||||
<button type="submit">Login</button>
|
</header>
|
||||||
</form>
|
<main>
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -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…
Reference in New Issue