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)