Add the logout button

Conceptually, to logout we have to “delete the session”, thus the best
HTTP verb would be `DELETE`.  However, there is no way to send a
`DELETE` request with a regular HTML form, and it seems that never will
be[0].

I could use a POST, optionally with a “method override” technique, but
i was planing to use HTMx anyway, so this was as good an opportunity to
include it as any.

In this application i am not concerned with people not having JavaScript
enabled, because it is for a customer that has a known environment, and
we do not have much time anyway.  Therefore, i opted to forgo
progressive enhancement in cases like this: if `DELETE` is needed, use
`hx-delete`.

Unfortunately, i can not use a <form> with a hidden <input> for the
CSRF token, because `DELETE` requests do not have body and the value
should be added as query parameters, like a form with GET method, but
HTMx does the incorrect thing here: sends the values in the request’s
body.  That’s why i have to use a custom header and the `hx-header`
directive to include the CSRF token.

Then, by default HTMx targets the triggered element for swap with the
response from the server, but after a logout i want to redirect the
user to the login form again.  I could set the hx-target to button to
replace the whole body, or tell the client to redirect to the new
location.  I actually do not know which one is “better”.  Maybe the
hx-target is best because then everything is handled by the client, but
in the case of logout, since it is possible that i might want to load
scripts only for logged-in users in the future, i opted for the full
page reload.

However, HTMx does not want to reload a page that return HTTP 401,
hence i had to include the GET method to /login in order to return the
login form with a response of HTTP 200, which also helps when
reloading in the browser after a failed login attempt.  I am not worried
with the HTTP 401 when attempting to load a page as guest, because
this request most probably comes from the browser, not HTMx, and it will
show the login form as intended—even though it is not compliant, since
it does not return the WWW-Authenticate header, but this is the best i
can do given that no cookie-based authentication method has been
accepted[1].

[0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16
[1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
This commit is contained in:
jordi fita mas 2023-07-26 13:49:47 +02:00
parent 2f3fc8812d
commit ebe8217862
12 changed files with 195 additions and 33 deletions

View File

@ -58,7 +58,7 @@ func New(db *database.DB) http.Handler {
} }
func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestURL := r.URL.Path requestPath := r.URL.Path
var head string var head string
head, r.URL.Path = shiftPath(r.URL.Path) head, r.URL.Path = shiftPath(r.URL.Path)
if head == "static" { if head == "static" {
@ -77,18 +77,23 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if head == "login" { if head == "login" {
switch r.Method { switch r.Method {
case http.MethodGet:
serveLoginForm(w, r, user, "/")
case http.MethodPost: case http.MethodPost:
handleLogin(w, r, user, conn) handleLogin(w, r, user, conn)
default: default:
methodNotAllowed(w, r, http.MethodPost) methodNotAllowed(w, r, http.MethodPost, http.MethodGet)
} }
} else { } else {
if !user.LoggedIn { if !user.LoggedIn {
serveLoginForm(w, r, user, requestURL) w.WriteHeader(http.StatusUnauthorized)
serveLoginForm(w, r, user, requestPath)
return return
} }
switch head { switch head {
case "me":
profileHandler(user, conn)(w, r)
case "": case "":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:

View File

@ -60,10 +60,9 @@ func (f *loginForm) Valid(l *locale.Locale) bool {
return v.AllOK return v.AllOK
} }
func serveLoginForm(w http.ResponseWriter, _ *http.Request, user *auth.User, requestURL string) { func serveLoginForm(w http.ResponseWriter, _ *http.Request, user *auth.User, redirectPath string) {
login := newLoginForm() login := newLoginForm()
login.Redirect.Val = requestURL login.Redirect.Val = redirectPath
w.WriteHeader(http.StatusUnauthorized)
template.MustRender(w, user, "login.gohtml", login) template.MustRender(w, user, "login.gohtml", login)
} }
@ -77,7 +76,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *
cookie := conn.MustGetText(r.Context(), "select login($1, $2, $3)", login.Email, login.Password, httplib.RemoteAddr(r)) cookie := conn.MustGetText(r.Context(), "select login($1, $2, $3)", login.Email, login.Password, httplib.RemoteAddr(r))
if cookie != "" { if cookie != "" {
auth.SetSessionCookie(w, cookie) auth.SetSessionCookie(w, cookie)
http.Redirect(w, r, login.Redirect.Val, http.StatusSeeOther) httplib.Redirect(w, r, login.Redirect.Val, http.StatusSeeOther)
return return
} }
login.Error = errors.New(user.Locale.Gettext("Invalid user or password.")) login.Error = errors.New(user.Locale.Gettext("Invalid user or password."))
@ -87,3 +86,13 @@ func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *
} }
template.MustRender(w, user, "login.gohtml", login) template.MustRender(w, user, "login.gohtml", login)
} }
func handleLogout(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn.MustExec(r.Context(), "select logout()")
auth.DeleteSessionCookie(w)
httplib.Redirect(w, r, "/login", http.StatusSeeOther)
}

View File

@ -23,7 +23,7 @@ func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error)
} }
row := conn.QueryRow(r.Context(), "select coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile") row := conn.QueryRow(r.Context(), "select coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
var langTag string var langTag string
if err := row.Scan(&user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CsrfToken); err != nil { if err := row.Scan(&user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
return nil, err return nil, err
} }
if lang, err := language.Parse(langTag); err == nil { if lang, err := language.Parse(langTag); err == nil {
@ -47,3 +47,22 @@ func (h *App) matchLocale(r *http.Request) *locale.Locale {
} }
return l return l
} }
func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "session":
switch r.Method {
case http.MethodDelete:
handleLogout(w, r, user, conn)
default:
methodNotAllowed(w, r, http.MethodDelete)
}
default:
http.NotFound(w, r)
}
}
}

View File

@ -8,25 +8,12 @@ package auth
import ( import (
"net/http" "net/http"
"time" "time"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/locale"
) )
const ( const (
sessionCookie = "camper-session" sessionCookie = "camper-session"
) )
type User struct {
Email string
LoggedIn bool
Role string
Language language.Tag
CsrfToken string
Locale *locale.Locale
}
func SetSessionCookie(w http.ResponseWriter, cookie string) { func SetSessionCookie(w http.ResponseWriter, cookie string) {
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour)) http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
} }
@ -38,6 +25,10 @@ func GetSessionCookie(r *http.Request) string {
return "" return ""
} }
func DeleteSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, createSessionCookie("", -24*time.Hour))
}
func createSessionCookie(value string, duration time.Duration) *http.Cookie { func createSessionCookie(value string, duration time.Duration) *http.Cookie {
return &http.Cookie{ return &http.Cookie{
Name: sessionCookie, Name: sessionCookie,

40
pkg/auth/user.go Normal file
View File

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package auth
import (
"errors"
"net/http"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/locale"
)
const (
CSRFTokenField = "csrf_token"
CSRFTokenHeader = "X-CSRFToken"
)
type User struct {
Email string
LoggedIn bool
Role string
Language language.Tag
CSRFToken string
Locale *locale.Locale
}
func (user *User) VerifyCSRFToken(r *http.Request) error {
token := r.Header.Get(CSRFTokenHeader)
if token == "" {
token = r.FormValue(CSRFTokenField)
}
if user.CSRFToken == token {
return nil
}
return errors.New(user.Locale.Gettext("Cross-site request forgery detected."))
}

View File

@ -9,6 +9,7 @@ import (
"context" "context"
"log" "log"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool" "github.com/jackc/pgx/v4/pgxpool"
) )
@ -57,6 +58,14 @@ type Conn struct {
*pgxpool.Conn *pgxpool.Conn
} }
func (c *Conn) MustExec(ctx context.Context, sql string, args ...interface{}) pgconn.CommandTag {
tag, err := c.Conn.Exec(ctx, sql, args...)
if err != nil {
panic(err)
}
return tag
}
func (c *Conn) MustGetText(ctx context.Context, sql string, args ...interface{}) string { func (c *Conn) MustGetText(ctx context.Context, sql string, args ...interface{}) string {
var result string var result string
if err := c.QueryRow(ctx, sql, args...).Scan(&result); err != nil { if err := c.QueryRow(ctx, sql, args...).Scan(&result); err != nil {

55
pkg/http/htmx.go Normal file
View File

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package http
import (
"encoding/json"
"net/http"
)
const (
HxLocation = "HX-Location"
HxRedirect = "HX-Redirect"
HxRequest = "HX-Request"
)
func Relocate(w http.ResponseWriter, r *http.Request, url string, code int) {
if IsHTMxRequest(r) {
w.Header().Set(HxLocation, MustMarshalHTMxLocation(&HTMxLocation{
Path: url,
Target: "main",
}))
w.WriteHeader(http.StatusNoContent)
} else {
http.Redirect(w, r, url, code)
}
}
func Redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
if IsHTMxRequest(r) {
w.Header().Set(HxRedirect, url)
w.WriteHeader(http.StatusNoContent)
} else {
http.Redirect(w, r, url, code)
}
}
func IsHTMxRequest(r *http.Request) bool {
return r.Header.Get(HxRequest) == "true"
}
type HTMxLocation struct {
Path string `json:"path"`
Target string `json:"target"`
}
func MustMarshalHTMxLocation(location *HTMxLocation) string {
data, err := json.Marshal(location)
if err != nil {
panic(err)
}
return string(data)
}

View File

@ -6,6 +6,7 @@
package template package template
import ( import (
"fmt"
"html/template" "html/template"
"io" "io"
"net/http" "net/http"
@ -30,6 +31,15 @@ func mustRenderLayout(w io.Writer, user *auth.User, layout string, filename stri
"currentLocale": func() string { "currentLocale": func() string {
return user.Locale.Language.String() return user.Locale.Language.String()
}, },
"isLoggedIn": func() bool {
return user.LoggedIn
},
"CSRFHeader": func() string {
return fmt.Sprintf(`"%s": "%s"`, auth.CSRFTokenHeader, user.CSRFToken)
},
"CSRFInput": func() template.HTML {
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, auth.CSRFTokenField, user.CSRFToken))
},
}) })
if _, err := t.ParseFiles(templateFile(layout), templateFile(filename)); err != nil { if _, err := t.ParseFiles(templateFile(layout), templateFile(filename)); err != nil {
panic(err) panic(err)

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 11:57+0200\n" "POT-Creation-Date: 2023-07-26 13:28+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n" "PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -43,22 +43,31 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
#: web/templates/layout.gohtml:15 #: web/templates/layout.gohtml:16
msgid "Skip to main content" msgid "Skip to main content"
msgstr "Salta al contingut principal" msgstr "Salta al contingut principal"
#: pkg/app/login.go:58 #: web/templates/layout.gohtml:20
msgctxt "action"
msgid "Logout"
msgstr "Surt"
#: pkg/app/login.go:56
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podeu deixar el correu en blanc." msgstr "No podeu deixar el correu en blanc."
#: pkg/app/login.go:59 #: pkg/app/login.go:57
msgid "This email is not valid. It should be like name@domain.com." msgid "This email is not valid. It should be like name@domain.com."
msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com." msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
#: pkg/app/login.go:61 #: pkg/app/login.go:59
msgid "Password can not be empty." msgid "Password can not be empty."
msgstr "No podeu deixar la contrasenya en blanc." msgstr "No podeu deixar la contrasenya en blanc."
#: pkg/app/login.go:85 #: pkg/app/login.go:82
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes." msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/auth/user.go:39
msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 11:57+0200\n" "POT-Creation-Date: 2023-07-26 13:28+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n" "PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -43,22 +43,31 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
#: web/templates/layout.gohtml:15 #: web/templates/layout.gohtml:16
msgid "Skip to main content" msgid "Skip to main content"
msgstr "Saltar al contenido principal" msgstr "Saltar al contenido principal"
#: pkg/app/login.go:58 #: web/templates/layout.gohtml:20
msgctxt "action"
msgid "Logout"
msgstr "Salir"
#: pkg/app/login.go:56
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco." msgstr "No podéis dejar el correo-e en blanco."
#: pkg/app/login.go:59 #: pkg/app/login.go:57
msgid "This email is not valid. It should be like name@domain.com." msgid "This email is not valid. It should be like name@domain.com."
msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com." msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com."
#: pkg/app/login.go:61 #: pkg/app/login.go:59
msgid "Password can not be empty." msgid "Password can not be empty."
msgstr "No podéis dejar la contraseña en blanco." msgstr "No podéis dejar la contraseña en blanco."
#: pkg/app/login.go:85 #: pkg/app/login.go:82
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Usuario o contraseña incorrectos." msgstr "Usuario o contraseña incorrectos."
#: pkg/auth/user.go:39
msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."

1
web/static/htmx@1.9.3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -9,11 +9,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ template "title" . }} — Camper</title> <title>{{ template "title" . }} — Camper</title>
<link rel="stylesheet" media="screen" href="/static/camper.css"> <link rel="stylesheet" media="screen" href="/static/camper.css">
<script src="/static/htmx@1.9.3.min.js"></script>
</head> </head>
<body> <body>
<header> <header>
<a href="#main">{{( gettext "Skip to main content" )}}</a> <a href="#main">{{( gettext "Skip to main content" )}}</a>
<h1>camper _ws</h1> <h1>camper _ws</h1>
{{ if isLoggedIn -}}
<button data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }'
>{{( pgettext "Logout" "action" )}}</button>
{{- end }}
</header> </header>
<main id="main"> <main id="main">
{{- template "content" . }} {{- template "content" . }}