feat(webui): bootstrap session handling and login process

This commit is contained in:
Julien Dessaux 2025-01-06 00:41:32 +01:00
parent 63e2b1b09d
commit 6e069484cb
Signed by: adyxax
GPG key ID: F92E51B86E07177E
18 changed files with 447 additions and 1 deletions

View file

@ -47,6 +47,38 @@ func (db *DB) InitAdminAccount() error {
}) })
} }
func (db *DB) LoadAccountById(id int) (*model.Account, error) {
account := model.Account{
Id: id,
}
var (
created int64
lastLogin int64
)
err := db.QueryRow(
`SELECT username, salt, password_hash, is_admin, created, last_login, settings
FROM accounts
WHERE id = ?;`,
id,
).Scan(&account.Username,
&account.Salt,
&account.PasswordHash,
&account.IsAdmin,
&created,
&lastLogin,
&account.Settings,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to load account by id %d: %w", id, err)
}
account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0)
return &account, nil
}
func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) { func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
account := model.Account{ account := model.Account{
Username: username, Username: username,
@ -72,7 +104,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return nil, err return nil, fmt.Errorf("failed to load account by username %s: %w", username, err)
} }
account.Created = time.Unix(created, 0) account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0) account.LastLogin = time.Unix(lastLogin, 0)

69
pkg/database/sessions.go Normal file
View file

@ -0,0 +1,69 @@
package database
import (
"database/sql"
"errors"
"fmt"
"time"
"git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
)
func (db *DB) CreateSession(account *model.Account) (string, error) {
var sessionId uuid.UUID
if err := sessionId.Generate(uuid.V4); err != nil {
return "", fmt.Errorf("failed to generate session id: %w", err)
}
if _, err := db.Exec(
`INSERT INTO sessions(id, account_id, data)
VALUES (?, ?, ?);`,
sessionId.String(),
account.Id,
"",
); err != nil {
return "", fmt.Errorf("failed insert new session in database: %w", err)
}
return sessionId.String(), nil
}
func (db *DB) LoadSessionById(id string) (*model.Session, error) {
session := model.Session{
Id: id,
}
var (
created int64
updated int64
)
err := db.QueryRow(
`SELECT account_id,
created,
updated,
data
FROM sessions
WHERE id = ?;`,
id,
).Scan(&session.AccountId,
&created,
&updated,
&session.Data,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to load session by id %s: %w", id, err)
}
session.Created = time.Unix(created, 0)
session.Updated = time.Unix(updated, 0)
return &session, nil
}
func (db *DB) TouchSession(sessionId string) error {
now := time.Now().UTC()
_, err := db.Exec(`UPDATE sessions SET updated = ? WHERE id = ?`, now.Unix(), sessionId)
if err != nil {
return fmt.Errorf("failed to touch updated for session %s: %w", sessionId, err)
}
return nil
}

View file

@ -14,6 +14,15 @@ CREATE TABLE accounts (
) STRICT; ) STRICT;
CREATE UNIQUE INDEX accounts_username on accounts(username); CREATE UNIQUE INDEX accounts_username on accounts(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
created INTEGER NOT NULL DEFAULT (unixepoch()),
updated INTEGER NOT NULL DEFAULT (unixepoch()),
data TEXT NOT NULL,
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
) STRICT;
CREATE TABLE states ( CREATE TABLE states (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
path TEXT NOT NULL, path TEXT NOT NULL,

20
pkg/model/session.go Normal file
View file

@ -0,0 +1,20 @@
package model
import (
"time"
)
type SessionContextKey struct{}
type Session struct {
Id string
AccountId int
Created time.Time
Updated time.Time
Data any
}
func (session *Session) IsExpired() bool {
// TODO
return false
}

10
pkg/webui/cache.go Normal file
View file

@ -0,0 +1,10 @@
package webui
import "net/http"
func cache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
next.ServeHTTP(w, r)
})
}

21
pkg/webui/error.go Normal file
View file

@ -0,0 +1,21 @@
package webui
import (
"html/template"
"net/http"
)
var errorTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/error.html"))
func errorResponse(w http.ResponseWriter, status int, err error) {
type ErrorData struct {
Err error
Status int
StatusText string
}
render(w, errorTemplates, status, &ErrorData{
Err: err,
Status: status,
StatusText: http.StatusText(status),
})
}

20
pkg/webui/html/base.html Normal file
View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/main.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<title>tfstated</title>
</head>
<body>
<header>
</header>
<main class="container">
{{ template "main" . }}
</main>
<footer>
</footer>
</body>
</html>

View file

@ -0,0 +1,6 @@
{{ define "main" }}
<article>
<h1>{{ .Status }} - {{ .StatusText }}</h1>
<p>{{ .Err }}</p>
</article>
{{ end }}

View file

@ -0,0 +1,3 @@
{{ define "main" }}
<h1>TODO</h1>
{{ end }}

29
pkg/webui/html/login.html Normal file
View file

@ -0,0 +1,29 @@
{{ define "main" }}
{{ if .Forbidden }}
<article>
<p class="error-message">Invalid username or password</p>
</article>
{{ end }}
<form action="/login" method="post">
<fieldset>
<label>
Username
<input type="text"
placeholder="Username"
name="username"
value="{{ .Username }}"
{{ if .Forbidden }}aria-invalid="true"{{ end }}
required>
</label>
<label>
Password
<input type="password"
placeholder="Password"
name="password"
{{ if .Forbidden }}aria-invalid="true"{{ end }}
required>
</label>
</fieldset>
<button type="submit" value="login">Login</button>
</form>
{{ end }}

16
pkg/webui/index.go Normal file
View file

@ -0,0 +1,16 @@
package webui
import (
"html/template"
"net/http"
)
var indexTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/index.html"))
func handleIndexGET() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache")
render(w, indexTemplates, http.StatusOK, nil)
})
}

115
pkg/webui/login.go Normal file
View file

@ -0,0 +1,115 @@
package webui
import (
"context"
"fmt"
"html/template"
"net/http"
"regexp"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
)
var loginTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/login.html"))
type loginPage struct {
Forbidden bool
Username string
}
func handleLoginGET() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache")
session := r.Context().Value(model.SessionContextKey{})
if session != nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
render(w, loginTemplate, http.StatusOK, loginPage{})
})
}
func handleLoginPOST(db *database.DB) http.Handler {
var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`)
renderForbidden := func(w http.ResponseWriter, username string) {
render(w, loginTemplate, http.StatusForbidden, loginPage{
Forbidden: true,
Username: username,
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
errorResponse(w, http.StatusBadRequest, err)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" { // the webui cannot issue this
errorResponse(w, http.StatusBadRequest, fmt.Errorf("Forbidden"))
return
}
if ok := validUsername.MatchString(username); !ok {
renderForbidden(w, username)
return
}
account, err := db.LoadAccountByUsername(username)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
if account == nil || !account.CheckPassword(password) {
renderForbidden(w, username)
return
}
if err := db.TouchAccount(account); err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
sessionId, err := db.CreateSession(account)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: sessionId,
Quoted: false,
Path: "/",
MaxAge: 8 * 3600, // 1 hour sessions
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
})
http.Redirect(w, r, "/", http.StatusFound)
})
}
func loginMiddleware(db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache")
session := r.Context().Value(model.SessionContextKey{})
if session == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
account, err := db.LoadAccountById(session.(*model.Session).AccountId)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
if account == nil {
// this could happen if the account was deleted in the short
// time between retrieving the session and here
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

22
pkg/webui/render.go Normal file
View file

@ -0,0 +1,22 @@
package webui
import (
"bytes"
"fmt"
"html/template"
"net/http"
)
func render(w http.ResponseWriter, t *template.Template, status int, data any) {
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base.html", data); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf(
"%s: failed to execute template: %+v",
http.StatusText(http.StatusInternalServerError),
err)))
} else {
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
}

View file

@ -10,5 +10,11 @@ func addRoutes(
mux *http.ServeMux, mux *http.ServeMux,
db *database.DB, db *database.DB,
) { ) {
session := sessionsMiddleware(db)
requireLogin := loginMiddleware(db)
mux.Handle("GET /healthz", handleHealthz()) mux.Handle("GET /healthz", handleHealthz())
mux.Handle("GET /login", session(handleLoginGET()))
mux.Handle("POST /login", session(handleLoginPOST(db)))
mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS))))
mux.Handle("GET /", session(requireLogin(handleIndexGET())))
} }

View file

@ -2,6 +2,7 @@ package webui
import ( import (
"context" "context"
"embed"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
@ -10,6 +11,12 @@ import (
"git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger" "git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger"
) )
//go:embed html/*
var htmlFS embed.FS
//go:embed static/*
var staticFS embed.FS
func Run( func Run(
ctx context.Context, ctx context.Context,
db *database.DB, db *database.DB,

55
pkg/webui/sessions.go Normal file
View file

@ -0,0 +1,55 @@
package webui
import (
"context"
"errors"
"fmt"
"net/http"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model"
)
const cookieName = "tfstated"
func sessionsMiddleware(db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to get request cookie \"%s\": %w", cookieName, err))
return
}
if err == nil {
if len(cookie.Value) != 36 {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Quoted: false,
Path: "/",
MaxAge: 0, // remove invalid cookie
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
})
} else {
session, err := db.LoadSessionById(cookie.Value)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
if !session.IsExpired() {
if err := db.TouchSession(cookie.Value); err != nil {
errorResponse(w, http.StatusInternalServerError, err)
return
}
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(0, 0, 0);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="98px" height="94px" viewBox="-0.5 -0.5 98 94"><defs/><rect fill="#000000" width="100%" height="100%" x="0" y="0"/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="d8vDQkJiZMsKFp_Zf0So-1"><g><rect x="0" y="0" width="98" height="94" fill="none" stroke="none" pointer-events="all"/><path d="M 89.23 80.02 L 89.23 31.87 L 76.66 31.87 L 76.66 80.02 L 66.61 80.02 L 66.61 31.87 L 54.04 31.87 L 54.04 80.02 L 43.99 80.02 L 43.99 31.87 L 31.42 31.87 L 31.42 80.02 L 21.36 80.02 L 21.36 31.87 L 8.8 31.87 L 8.8 80.02 L 8.79 80.02 L 0 94 L 98 93.98 Z M 2.49 26.57 L 95.51 26.57 L 49 0 Z" fill="#e6e6e6" stroke="none" pointer-events="all"/></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 948 B

View file

@ -0,0 +1,3 @@
.error-message {
color:red;
}