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