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:
parent
2f3fc8812d
commit
ebe8217862
|
@ -58,7 +58,7 @@ func New(db *database.DB) http.Handler {
|
|||
}
|
||||
|
||||
func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
requestURL := r.URL.Path
|
||||
requestPath := r.URL.Path
|
||||
var head string
|
||||
head, r.URL.Path = shiftPath(r.URL.Path)
|
||||
if head == "static" {
|
||||
|
@ -77,18 +77,23 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if head == "login" {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveLoginForm(w, r, user, "/")
|
||||
case http.MethodPost:
|
||||
handleLogin(w, r, user, conn)
|
||||
default:
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
methodNotAllowed(w, r, http.MethodPost, http.MethodGet)
|
||||
}
|
||||
} else {
|
||||
if !user.LoggedIn {
|
||||
serveLoginForm(w, r, user, requestURL)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
serveLoginForm(w, r, user, requestPath)
|
||||
return
|
||||
}
|
||||
|
||||
switch head {
|
||||
case "me":
|
||||
profileHandler(user, conn)(w, r)
|
||||
case "":
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
|
|
|
@ -60,10 +60,9 @@ func (f *loginForm) Valid(l *locale.Locale) bool {
|
|||
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.Redirect.Val = requestURL
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
login.Redirect.Val = redirectPath
|
||||
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))
|
||||
if cookie != "" {
|
||||
auth.SetSessionCookie(w, cookie)
|
||||
http.Redirect(w, r, login.Redirect.Val, http.StatusSeeOther)
|
||||
httplib.Redirect(w, r, login.Redirect.Val, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
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
|
||||
}
|
||||
if lang, err := language.Parse(langTag); err == nil {
|
||||
|
@ -47,3 +47,22 @@ func (h *App) matchLocale(r *http.Request) *locale.Locale {
|
|||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,25 +8,12 @@ package auth
|
|||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"dev.tandem.ws/tandem/camper/pkg/locale"
|
||||
)
|
||||
|
||||
const (
|
||||
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) {
|
||||
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
|
||||
}
|
||||
|
@ -38,6 +25,10 @@ func GetSessionCookie(r *http.Request) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func DeleteSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, createSessionCookie("", -24*time.Hour))
|
||||
}
|
||||
|
||||
func createSessionCookie(value string, duration time.Duration) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
|
|
|
@ -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."))
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
)
|
||||
|
@ -57,6 +58,14 @@ type Conn struct {
|
|||
*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 {
|
||||
var result string
|
||||
if err := c.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
|
@ -30,6 +31,15 @@ func mustRenderLayout(w io.Writer, user *auth.User, layout string, filename stri
|
|||
"currentLocale": func() 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 {
|
||||
panic(err)
|
||||
|
|
21
po/ca.po
21
po/ca.po
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: camper\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"
|
||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||
"Language-Team: Catalan <ca@dodds.net>\n"
|
||||
|
@ -43,22 +43,31 @@ msgctxt "action"
|
|||
msgid "Login"
|
||||
msgstr "Entra"
|
||||
|
||||
#: web/templates/layout.gohtml:15
|
||||
#: web/templates/layout.gohtml:16
|
||||
msgid "Skip to main content"
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
msgstr "No podeu deixar la contrasenya en blanc."
|
||||
|
||||
#: pkg/app/login.go:85
|
||||
#: pkg/app/login.go:82
|
||||
msgid "Invalid user or password."
|
||||
msgstr "Nom d’usuari o contrasenya incorrectes."
|
||||
|
||||
#: pkg/auth/user.go:39
|
||||
msgid "Cross-site request forgery detected."
|
||||
msgstr "S’ha detectat un intent de falsificació de petició a llocs creuats."
|
||||
|
|
21
po/es.po
21
po/es.po
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: camper\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"
|
||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||
"Language-Team: Spanish <es@tp.org.es>\n"
|
||||
|
@ -43,22 +43,31 @@ msgctxt "action"
|
|||
msgid "Login"
|
||||
msgstr "Entrar"
|
||||
|
||||
#: web/templates/layout.gohtml:15
|
||||
#: web/templates/layout.gohtml:16
|
||||
msgid "Skip to main content"
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -9,11 +9,16 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ template "title" . }} — Camper</title>
|
||||
<link rel="stylesheet" media="screen" href="/static/camper.css">
|
||||
<script src="/static/htmx@1.9.3.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="#main">{{( gettext "Skip to main content" )}}</a>
|
||||
<h1>camper _ws</h1>
|
||||
{{ if isLoggedIn -}}
|
||||
<button data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }'
|
||||
>{{( pgettext "Logout" "action" )}}</button>
|
||||
{{- end }}
|
||||
</header>
|
||||
<main id="main">
|
||||
{{- template "content" . }}
|
||||
|
|
Loading…
Reference in New Issue