Add login to web interface

This commit is contained in:
Tim Van Baak 2025-02-06 06:33:39 -08:00
parent 7cbf48a9b1
commit 2acd6f397f
8 changed files with 185 additions and 8 deletions

View File

@ -56,7 +56,7 @@ Additional features
* [ ] metric reporting * [ ] metric reporting
* [ ] on action failure, create an error item with logs * [ ] 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 * [ ] items gracefully add new fields and `action` keys
* [ ] arbitrary date punt * [ ] arbitrary date punt
* [ ] HTTP edit item * [ ] 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. 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. 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. 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`.

View File

@ -22,6 +22,15 @@ func SetPassword(db DB, password string) error {
return nil 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) { func CheckPassword(db DB, password string) (bool, error) {
var hash string var hash string
err := db.QueryRow("select hash from password limit 1").Scan(&hash) err := db.QueryRow("select hash from password limit 1").Scan(&hash)

View File

@ -45,3 +45,8 @@ create table password(
hash text, hash text,
unique (id) on conflict replace unique (id) on conflict replace
) strict; ) strict;
create table sessions(
id text not null,
expires int default 0,
primary key (id)
) strict;

View File

@ -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 all -s spook
tmp/intake channel add -c none -s nothing tmp/intake channel add -c none -s nothing
echo "hello" | tmp/intake passwd --stdin
echo "hello" | tmp/intake passwd --stdin --verify

View File

@ -128,3 +128,17 @@ func Item(writer io.Writer, data ItemData) {
log.Printf("error: failed to render item: %v", err) 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
}

16
web/html/login.html Normal file
View File

@ -0,0 +1,16 @@
{{ define "title" }}Intake - Login{{ end }}
{{ define "content" -}}
<article class="center">
<form method="post" action="/login">
<p>
<input name="password" type="password"/>
</p>
<button
hx-post="/login"
hx-target="#errors"
hx-swap="innerHTML"
>Submit</button>
</form>
<p id="errors">{{ .Error }}</p>
{{ end }}

123
web/login.go Normal file
View File

@ -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)
}

View File

@ -34,15 +34,16 @@ func RunServer(db core.DB, addr string, port string) {
env := &Env{db} env := &Env{db}
bind := net.JoinHostPort(addr, port) 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 /style.css", env.getStyle, logged)
handleFunc("GET /htmx.org@2.0.4.js", env.getScript, logged) handleFunc("GET /htmx.org@2.0.4.js", env.getScript, logged)
handleFunc("GET /source/{source}", env.getSource, logged) handleFunc("POST /login", env.login, logged)
handleFunc("GET /channel/{channel}", env.getChannel, logged) handleFunc("GET /source/{source}", env.getSource, env.authed, logged)
handleFunc("GET /item/{source}/{id}", env.getItem, logged) handleFunc("GET /channel/{channel}", env.getChannel, env.authed, logged)
handleFunc("DELETE /item/{source}/{id}", env.deleteItem, logged) handleFunc("GET /item/{source}/{id}", env.getItem, env.authed, logged)
handleFunc("POST /item/{source}/{id}/action/{action}", env.doAction, logged) handleFunc("DELETE /item/{source}/{id}", env.deleteItem, env.authed, logged)
handleFunc("POST /mass-deactivate", env.massDeactivate, 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)) log.Fatal(http.ListenAndServe(bind, nil))
} }