diff --git a/assets/page.html b/assets/page.html
index 4d550da..6176607 100644
--- a/assets/page.html
+++ b/assets/page.html
@@ -14,8 +14,9 @@
{{if .EditMode}}
CANCEL
diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..589b2db
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,97 @@
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ authCookieName = "datascape_auth"
+ authCookieMaxAge = 10 * 365 * 24 * 3600
+)
+
+// loadOrCreateAuthKey returns a stable 32-byte HMAC key persisted in the wiki
+// root as `.auth-key`. A stable key means sessions survive restarts.
+func loadOrCreateAuthKey(wikiDir string) ([]byte, error) {
+ p := filepath.Join(wikiDir, ".auth-key")
+ if data, err := os.ReadFile(p); err == nil && len(data) >= 32 {
+ return data, nil
+ }
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(p, key, 0600); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+func signAuth(key []byte) string {
+ payload := strconv.FormatInt(time.Now().Unix(), 10)
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(payload))
+ sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+ return payload + "." + sig
+}
+
+func verifyAuth(key []byte, value string) bool {
+ i := strings.IndexByte(value, '.')
+ if i <= 0 {
+ return false
+ }
+ payload, sig := value[:i], value[i+1:]
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(payload))
+ expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(sig), []byte(expected))
+}
+
+// checkAuth returns true if the request is authenticated. It accepts either a
+// valid signed cookie or HTTP Basic credentials; on successful basic auth it
+// issues a long-lived cookie so the browser stops re-prompting.
+func (h *handler) checkAuth(w http.ResponseWriter, r *http.Request) bool {
+ if h.user == "" {
+ return true
+ }
+ if c, err := r.Cookie(authCookieName); err == nil && verifyAuth(h.authKey, c.Value) {
+ return true
+ }
+ u, p, ok := r.BasicAuth()
+ if !ok || u != h.user || p != h.pass {
+ w.Header().Set("WWW-Authenticate", `Basic realm="datascape"`)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return false
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: authCookieName,
+ Value: signAuth(h.authKey),
+ Path: "/",
+ MaxAge: authCookieMaxAge,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ return true
+}
+
+// handleLogout clears the session cookie and forces a fresh basic-auth prompt.
+func (h *handler) handleLogout(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: authCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ w.Header().Set("WWW-Authenticate", `Basic realm="datascape"`)
+ http.Error(w, "Logged out", http.StatusUnauthorized)
+}
diff --git a/main.go b/main.go
index ef0f33c..bd12455 100644
--- a/main.go
+++ b/main.go
@@ -51,7 +51,11 @@ func main() {
log.Fatal(err)
}
- h := &handler{root: root, user: *user, pass: *pass}
+ authKey, err := loadOrCreateAuthKey(root)
+ if err != nil {
+ log.Fatal(err)
+ }
+ h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
@@ -61,6 +65,7 @@ func main() {
}
static.ServeHTTP(w, r)
}))
+ http.HandleFunc("/_logout", h.handleLogout)
http.Handle("/", h)
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
@@ -69,16 +74,12 @@ func main() {
type handler struct {
root, user, pass string
+ authKey []byte
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if h.user != "" {
- u, p, ok := r.BasicAuth()
- if !ok || u != h.user || p != h.pass {
- w.Header().Set("WWW-Authenticate", `Basic realm="datascape"`)
- http.Error(w, "Unauthorized", http.StatusUnauthorized)
- return
- }
+ if !h.checkAuth(w, r) {
+ return
}
urlPath := path.Clean("/" + r.URL.Path)