Compare commits

..

8 Commits

Author SHA1 Message Date
jordi fita mas c369364642 Add the SQL for the demo 2023-01-17 22:30:01 +01:00
jordi fita mas d434d040af Add the very basic styles 2023-01-17 22:28:47 +01:00
jordi fita mas f1bf1f896d Implement login cookie, its verification, and logout
At first i thought that i would need to implement sessions, the ones
that keep small files onto the disk, to know which user is talking to
the server, but then i realized that, for now at least, i only need a
very large number, plus the email address, to be used as a lookup, and
that can be stored in the user table, in a separate schema.

Had to change login to avoid raising exceptions when login failed
because i now keep a record of login attemps, and functions are always
run in a single transaction, thus the exception would prevent me to
insert into login_attempt.  Even if i use a separate procedure, i could
not keep the records.

I did not want to add a parameter to the logout function because i was
afraid that it could be called from separate users.  I do not know
whether it is possible with the current approach, since the settings
variable is also set by the same applications; time will tell.
2023-01-17 20:58:13 +01:00
jordi fita mas ab6c0079c9 Set search_path on each new connection, and role on each acquisition
The whole application will need the same search_path, so it is wasteful
to do that in each handler.

It is possible to pass the search path as a parameter to the database’s
connection string, but then everyone would need to remember to do that,
and update the configuration in case i add another schema.

Similarly, i need to change the user’s role to match her
permissions—which are not in yet—, but this time i need it each time a
handler requests a connection from the pool, because each time the
connection is returned to the pool i reset the role back to the initial,
that hopefully will be authenticator.
2023-01-17 14:49:02 +01:00
jordi fita mas 97ac586a3b “Merge” find_user_role and login
I honestly do not remember why i thought i needed the find_user
function: it is just a select with a query that i only need in a single
place—when login.

I belive it was a missguided attempt to “write the function safer”, in
hopes that calling a function won’t have the same problems as when
querying a table, but this is fixed with the search_path, that i added.

There is no pgTAP for this, i believe.
2023-01-17 13:18:12 +01:00
jordi fita mas 8fd22672c7 Create pgcrypto extension into auth schema
Will only be used there, no need to be in public, and this way i can
limit the search_path for security definer functions.
2023-01-17 13:12:18 +01:00
jordi fita mas 45439c8559 Remove the revocation of all function executions
I need to execute some functions in public for citext, such as
texticregexeq, or guest users would not be able to login.
2023-01-17 13:05:58 +01:00
jordi fita mas 989cdd7da7 Move source file to the root of pkg
I do not yet know how i will need to organize them, if indeed i need to
organize them to a finer granularity than single files, so there is no
point on doing Java-like packages.
2023-01-17 10:40:22 +01:00
59 changed files with 1180 additions and 204 deletions

View File

@ -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
View File

@ -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
View File

@ -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
View File

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

10
demo/demo.sql Normal file
View 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
View 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;

View File

@ -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"

View File

@ -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"

View File

@ -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;

View File

@ -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;

View File

@ -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 users 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
View 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
View 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;

View File

@ -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;

View File

@ -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
View 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)
}
}

View File

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

117
pkg/login.go Normal file
View 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
View 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
View 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
}

View File

@ -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
View File

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

View File

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

View File

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

7
revert/login_attempt.sql Normal file
View 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
View File

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

View File

@ -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 users 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 users 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 users role given its email and password
login [roles schema_numerus email user find_user_role] 2023-01-13T00:32:32Z jordi fita i mas <jfita@infoblitz.com> # Add function to login
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
View 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;

View File

@ -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;

View File

@ -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 users cookie.'
);
select results_eq(
$$ select cookie_expires_at > current_timestamp from "user" where email = 'info@tandem.blog' $$,
$$ values(true) $$,
'Should have set an expiry date in the future, again.'
);
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
View 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
View 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;

View File

@ -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
View File

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

View File

@ -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;

View File

@ -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
View 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
View File

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

View File

@ -8,6 +8,8 @@ select
, name
, password
, role
, cookie
, cookie_expires_at
, created_at
from auth."user"
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.

266
web/static/numerus.css Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -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
View 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>