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.
This commit is contained in:
parent
ab6c0079c9
commit
f1bf1f896d
|
@ -13,15 +13,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dbpool, err := numerus.ConnectToDatabase(context.Background(), os.Getenv("NUMERUS_DATABASE_URL"))
|
db, err := numerus.NewDatabase(context.Background(), os.Getenv("NUMERUS_DATABASE_URL"))
|
||||||
if err != nil {
|
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: numerus.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,
|
||||||
|
|
|
@ -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;
|
|
@ -2,40 +2,63 @@
|
||||||
-- requires: roles
|
-- requires: roles
|
||||||
-- requires: schema_numerus
|
-- requires: schema_numerus
|
||||||
-- requires: schema_auth
|
-- requires: schema_auth
|
||||||
|
-- requires: extension_pgcrypto
|
||||||
-- requires: email
|
-- requires: email
|
||||||
-- requires: user
|
-- requires: user
|
||||||
|
-- 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
|
||||||
user_role name;
|
user_cookie text;
|
||||||
begin
|
begin
|
||||||
select role
|
if not exists (
|
||||||
into user_role
|
select *
|
||||||
from "user"
|
from "user"
|
||||||
where "user".email = login.email
|
where "user".email = login.email
|
||||||
and "user".password = crypt(login.password, "user".password);
|
and "user".password = crypt(login.password, "user".password)
|
||||||
|
) then
|
||||||
if user_role is null then
|
insert into login_attempt
|
||||||
raise invalid_password using message = 'invalid user or password';
|
(user_name, ip_address, success)
|
||||||
|
values (login.email, login.ip_address, false);
|
||||||
|
return '';
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
return user_role;
|
select cookie
|
||||||
|
into user_cookie
|
||||||
|
from "user"
|
||||||
|
where "user".email = login.email
|
||||||
|
and cookie_expires_at > current_timestamp
|
||||||
|
and length(cookie) > 30;
|
||||||
|
|
||||||
|
if user_cookie is null then
|
||||||
|
select encode(gen_random_bytes(25), 'hex') into user_cookie;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update "user"
|
||||||
|
set cookie = user_cookie
|
||||||
|
, cookie_expires_at = current_timestamp + interval '1 year'
|
||||||
|
where "user".email = login.email;
|
||||||
|
|
||||||
|
insert into login_attempt
|
||||||
|
(user_name, ip_address, success)
|
||||||
|
values (login.email, login.ip_address, true);
|
||||||
|
|
||||||
|
return user_cookie || '/' || email;
|
||||||
end;
|
end;
|
||||||
$$
|
$$
|
||||||
language plpgsql
|
language plpgsql
|
||||||
stable
|
|
||||||
security definer
|
security definer
|
||||||
set search_path = auth, numerus, pg_temp;
|
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.';
|
||||||
|
|
||||||
revoke execute on function login(email, text) from public;
|
revoke execute on function login(email, text, inet) from public;
|
||||||
grant execute on function login(email, text) to guest;
|
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;
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
50
pkg/db.go
50
pkg/db.go
|
@ -3,12 +3,17 @@ package pkg
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/jackc/pgx/v4/pgxpool"
|
"github.com/jackc/pgx/v4/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConnectToDatabase(ctx context.Context, connString string) (*pgxpool.Pool, error) {
|
type Db struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDatabase(ctx context.Context, connString string) (*Db, error) {
|
||||||
config, err := pgxpool.ParseConfig(connString)
|
config, err := pgxpool.ParseConfig(connString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -20,9 +25,18 @@ func ConnectToDatabase(ctx context.Context, connString string) (*pgxpool.Pool, e
|
||||||
}
|
}
|
||||||
|
|
||||||
config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
|
config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
|
||||||
if _, err := conn.Exec(ctx, "select set_config('role', $1, false)", "guest"); err != nil {
|
if user, ok := ctx.Value(ContextUserKey).(*AppUser); ok {
|
||||||
log.Printf("ERROR - Failed to set role: %v", err)
|
batch := &pgx.Batch{}
|
||||||
return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
@ -35,5 +49,31 @@ func ConnectToDatabase(ctx context.Context, connString string) (*pgxpool.Pool, e
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return pgxpool.ConnectConfig(ctx, config)
|
pool, err := pgxpool.ConnectConfig(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Db{pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Db) Close() {
|
||||||
|
db.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Db) Text(r *http.Request, def string, sql string, args ...interface{}) string {
|
||||||
|
var result string
|
||||||
|
if err := db.pool.QueryRow(r.Context(), sql, args...).Scan(&result); err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Db) Exec(r *http.Request, sql string, args ...interface{}) {
|
||||||
|
if _, err := db.pool.Exec(r.Context(), sql, args...); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -2,44 +2,39 @@ package pkg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v4/pgxpool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(db *pgxpool.Pool) http.Handler {
|
func NewRouter(db *Db) http.Handler {
|
||||||
router := http.NewServeMux()
|
router := http.NewServeMux()
|
||||||
router.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
router.Handle("/login", LoginHandler(db))
|
||||||
r.ParseForm()
|
router.Handle("/logout", LogoutHandler(db))
|
||||||
|
|
||||||
email := r.FormValue("email")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
var role string
|
|
||||||
err := db.QueryRow(r.Context(), "select login($1, $2)", email, password).Scan(&role)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ERROR - %v for %q", err, email)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(role))
|
|
||||||
})
|
|
||||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := getUser(r)
|
||||||
|
if user.LoggedIn {
|
||||||
t, err := template.ParseFiles("web/template/index.html")
|
t, err := template.ParseFiles("web/template/index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR - %s", err.Error())
|
panic(err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
err = t.Execute(w, nil)
|
err = t.Execute(w, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR - %s", err.Error())
|
panic(err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
} 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
|
var handler http.Handler = router
|
||||||
|
handler = CheckLogin(db, handler)
|
||||||
|
handler = Recoverer(handler)
|
||||||
handler = Logger(handler)
|
handler = Logger(handler)
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Revert numerus:check_cookie from pg
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
drop function if exists numerus.check_cookie(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;
|
|
@ -12,4 +12,7 @@ user [roles schema_auth email] 2023-01-12T23:44:03Z jordi fita i mas <jfita@info
|
||||||
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_auth] 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
|
||||||
login [roles schema_numerus schema_auth email user] 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
|
||||||
|
|
|
@ -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;
|
|
@ -5,47 +5,99 @@ reset client_min_messages;
|
||||||
|
|
||||||
begin;
|
begin;
|
||||||
|
|
||||||
select plan(12);
|
select plan(20);
|
||||||
|
|
||||||
set search_path to numerus, auth, public;
|
set search_path to auth, numerus, public;
|
||||||
|
|
||||||
select has_function('login');
|
select 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;
|
|
@ -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;
|
||||||
|
|
|
@ -7,15 +7,9 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Numerus</h1>
|
<h1>Numerus</h1>
|
||||||
<h2>Login</h2>
|
<h2>Welcome</h2>
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/logout">
|
||||||
<label for="user_email">Email</label>
|
<button type="submit">Logout</button>
|
||||||
<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>
|
</form>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Login — Numerus</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Numerus</h1>
|
||||||
|
<h2>Login</h2>
|
||||||
|
{{ if .LoginError }}
|
||||||
|
<p>{{ .LoginError }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<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>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue