Add login to web interface
This commit is contained in:
parent
7cbf48a9b1
commit
2acd6f397f
@ -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`.
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
16
web/html/login.html
Normal file
16
web/html/login.html
Normal 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
123
web/login.go
Normal 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)
|
||||
}
|
15
web/main.go
15
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))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user