From 2acd6f397f7ecf0fd822ddee6782997f45a3410b Mon Sep 17 00:00:00 2001 From: Tim Van Baak Date: Thu, 6 Feb 2025 06:33:39 -0800 Subject: [PATCH] Add login to web interface --- README.md | 8 +- core/passwd.go | 9 +++ core/sql/0001_initial_schema.sql | 5 ++ test/test_items.sh | 3 + web/html/html.go | 14 ++++ web/html/login.html | 16 ++++ web/login.go | 123 +++++++++++++++++++++++++++++++ web/main.go | 15 ++-- 8 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 web/html/login.html create mode 100644 web/login.go diff --git a/README.md b/README.md index 8fdb559..5038571 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Additional features * [ ] metric reporting * [ ] on action failure, create an error item with logs -* [ ] first-party password handling instead of basic auth and htpasswd +* [x] first-party password handling instead of basic auth and htpasswd * [ ] items gracefully add new fields and `action` keys * [ ] arbitrary date punt * [ ] HTTP edit item @@ -148,3 +148,9 @@ If an item's `on_create` fails, the item is still created, but without any chang The special action `on_delete` is like `on_create`, except it runs right before an item is deleted. It does not require explicit support and is not accessible in the web interface. The output of `on_delete` is ignored; it is primarily for causing side effects like managing state. + +### Web interface + +The `intake serve` command runs an HTTP server that gives access to the feed. +While the CLI can rely on normal filesystem access control to secure the database, this does not apply to HTTP. +Instead, the web interface can be locked behind a password set via `intake passwd`. diff --git a/core/passwd.go b/core/passwd.go index 64f2e1b..106e4b1 100644 --- a/core/passwd.go +++ b/core/passwd.go @@ -22,6 +22,15 @@ func SetPassword(db DB, password string) error { return nil } +func HasPassword(db DB) (bool, error) { + var i int + err := db.QueryRow("select count(*) from password").Scan(&i) + if err != nil { + return false, err + } + return i > 0, nil +} + func CheckPassword(db DB, password string) (bool, error) { var hash string err := db.QueryRow("select hash from password limit 1").Scan(&hash) diff --git a/core/sql/0001_initial_schema.sql b/core/sql/0001_initial_schema.sql index a4d587d..1139c94 100644 --- a/core/sql/0001_initial_schema.sql +++ b/core/sql/0001_initial_schema.sql @@ -45,3 +45,8 @@ create table password( hash text, unique (id) on conflict replace ) strict; +create table sessions( + id text not null, + expires int default 0, + primary key (id) +) strict; diff --git a/test/test_items.sh b/test/test_items.sh index e383e10..88aff4e 100755 --- a/test/test_items.sh +++ b/test/test_items.sh @@ -35,3 +35,6 @@ tmp/intake channel add -c all -s feedtest tmp/intake channel add -c all -s spook tmp/intake channel add -c none -s nothing + +echo "hello" | tmp/intake passwd --stdin +echo "hello" | tmp/intake passwd --stdin --verify diff --git a/web/html/html.go b/web/html/html.go index c78fb05..72bf212 100644 --- a/web/html/html.go +++ b/web/html/html.go @@ -128,3 +128,17 @@ func Item(writer io.Writer, data ItemData) { log.Printf("error: failed to render item: %v", err) } } + +var login = load("login.html") + +type LoginData struct { + Error string +} + +func Login(writer io.Writer, data LoginData) error { + err := login.Execute(writer, data) + if err != nil { + log.Printf("render error: %v", err) + } + return err +} diff --git a/web/html/login.html b/web/html/login.html new file mode 100644 index 0000000..e41c5a6 --- /dev/null +++ b/web/html/login.html @@ -0,0 +1,16 @@ +{{ define "title" }}Intake - Login{{ end }} + +{{ define "content" -}} +
+
+

+ +

+ +
+

{{ .Error }}

+{{ end }} diff --git a/web/login.go b/web/login.go new file mode 100644 index 0000000..09bffcd --- /dev/null +++ b/web/login.go @@ -0,0 +1,123 @@ +package web + +import ( + "crypto/rand" + "database/sql" + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/Jaculabilis/intake/core" + "github.com/Jaculabilis/intake/web/html" +) + +var AuthCookieName string = "intake_auth" +var AuthDuration time.Duration = time.Hour * 24 * 7 + +func newSession(db core.DB) (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + session := fmt.Sprintf("%x", bytes) + expires := int(time.Now().Add(AuthDuration).Unix()) + _, err = db.Exec(` + insert into sessions (id, expires) + values (?, ?) + `, session, expires) + if err != nil { + return "", err + } + return session, nil +} + +func checkSession(db core.DB, session string) (bool, error) { + row := db.QueryRow(` + select expires + from sessions + where id = ? + `, session) + var expires int + if err := row.Scan(&expires); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + expiration := time.Unix(int64(expires), 0).UTC() + if time.Now().After(expiration) { + return false, nil + } + return true, nil +} + +func renderLoginWithErrorMessage(writer http.ResponseWriter, req *http.Request, message string) { + // If an htmx interaction caused the auth error, refresh the page to get the login rendered + if req.Header.Get("HX-Request") != "" { + writer.Header()["HX-Refresh"] = []string{"true"} + writer.WriteHeader(http.StatusForbidden) + return + } + + data := html.LoginData{Error: message} + if err := html.Login(writer, data); err != nil { + log.Printf("render error: %v", err) + } +} + +func (env *Env) authed(handler http.HandlerFunc) http.HandlerFunc { + return func(writer http.ResponseWriter, req *http.Request) { + required, err := core.HasPassword(env.db) + if err != nil { + renderLoginWithErrorMessage(writer, req, fmt.Sprintf("error: failed to check for password: %v", err)) + return + } + if required { + cookie, err := req.Cookie(AuthCookieName) + if errors.Is(err, http.ErrNoCookie) { + renderLoginWithErrorMessage(writer, req, "Your session is expired or invalid") + return + } + if valid, err := checkSession(env.db, cookie.Value); !valid || err != nil { + renderLoginWithErrorMessage(writer, req, "Your session is expired or invalid") + return + } + } + handler(writer, req) + } +} + +func (env *Env) login(writer http.ResponseWriter, req *http.Request) { + if err := req.ParseForm(); err != nil { + http.Error(writer, fmt.Sprintf("error: failed to parse form: %v", err), http.StatusOK) + return + } + password := req.PostForm.Get("password") + + pass, err := core.CheckPassword(env.db, password) + if err != nil { + http.Error(writer, fmt.Sprintf("error: failed to check password: %v", err), http.StatusOK) + return + } + if !pass { + http.Error(writer, "Incorrect password", http.StatusOK) + return + } + + session, err := newSession(env.db) + if err != nil { + http.Error(writer, fmt.Sprintf("error: failed to start session: %v", err), http.StatusOK) + return + } + + cookie := http.Cookie{ + Name: AuthCookieName, + Value: session, + } + http.SetCookie(writer, &cookie) + writer.Header()["HX-Refresh"] = []string{"true"} + writer.WriteHeader(http.StatusNoContent) +} diff --git a/web/main.go b/web/main.go index ae12bc3..637f025 100644 --- a/web/main.go +++ b/web/main.go @@ -34,15 +34,16 @@ func RunServer(db core.DB, addr string, port string) { env := &Env{db} bind := net.JoinHostPort(addr, port) - handleFunc("GET /", env.getRoot, logged) + handleFunc("GET /", env.getRoot, env.authed, logged) handleFunc("GET /style.css", env.getStyle, logged) handleFunc("GET /htmx.org@2.0.4.js", env.getScript, logged) - handleFunc("GET /source/{source}", env.getSource, logged) - handleFunc("GET /channel/{channel}", env.getChannel, logged) - handleFunc("GET /item/{source}/{id}", env.getItem, logged) - handleFunc("DELETE /item/{source}/{id}", env.deleteItem, logged) - handleFunc("POST /item/{source}/{id}/action/{action}", env.doAction, logged) - handleFunc("POST /mass-deactivate", env.massDeactivate, logged) + handleFunc("POST /login", env.login, logged) + handleFunc("GET /source/{source}", env.getSource, env.authed, logged) + handleFunc("GET /channel/{channel}", env.getChannel, env.authed, logged) + handleFunc("GET /item/{source}/{id}", env.getItem, env.authed, logged) + handleFunc("DELETE /item/{source}/{id}", env.deleteItem, env.authed, logged) + handleFunc("POST /item/{source}/{id}/action/{action}", env.doAction, env.authed, logged) + handleFunc("POST /mass-deactivate", env.massDeactivate, env.authed, logged) log.Fatal(http.ListenAndServe(bind, nil)) }