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