This commit is contained in:
ponzischeme89
2026-08-10 08:37:08 +12:00
parent d2f2eb62be
commit 78d26effbf
18 changed files with 592 additions and 44 deletions
+21 -2
View File
@@ -33,6 +33,7 @@ func (s *Server) adminRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("POST /admin/logout", s.handleAdminLogout)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
// One person's own page. It is a path rather than a query string so it can be linked,
// bookmarked and returned to after a sign-in, like every other page here.
@@ -148,7 +149,7 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return
}
if browser && operatorPresent(r) {
s.renewInstallerSession(w, r)
s.renewAdminSession(w, r)
}
h(w, r)
})
@@ -213,7 +214,7 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
return
}
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewInstallerSession(w, r)
s.renewAdminSession(w, r)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
@@ -230,6 +231,24 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
_, _ = w.Write(body)
}
func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
s.clearInstallerCookie(w)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Path: "/admin",
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
type adminStatus struct {
// ServerVersion is what the page's footer reports. An operator reading the live log
// needs to know which build wrote it, and the page is the one place that is asked.
+8 -3
View File
@@ -122,12 +122,16 @@ a { color: var(--accent-ink); }
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.rail-foot {
display: flex; align-items: center; gap: 9px;
display: grid; gap: 8px;
padding: 12px 12px 0; border-top: 1px solid var(--line);
}
.rail-status { display: flex; align-items: center; gap: 9px; min-width: 0; }
.rail-foot-copy { min-width: 0; }
.rail-foot-copy b { display: block; font-size: 12px; font-weight: 600; }
.rail-foot-copy span { display: block; color: var(--quiet); font-size: 11px; }
.rail-foot form { margin: 0; }
.rail-logout { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; }
.rail-logout .glyph { width: 22px; height: 22px; background: none; }
/* ---------- page ---------- */
@@ -485,9 +489,10 @@ details[open] summary { margin-bottom: 8px; }
:root { --rail: 62px; }
.rail { padding: 16px 7px 10px; }
.rail-brand { justify-content: center; padding: 0 0 14px; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy { display: none; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; }
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
.rail-foot { justify-content: center; padding: 12px 0 0; }
.rail-foot { justify-items: center; padding: 12px 0 0; }
.rail-logout { width: auto; padding: 5px; }
.grid.wide { grid-template-columns: 1fr; }
.page { padding: 20px 14px 44px; }
.table-wrap { margin: 0 -20px -18px; }
+1 -1
View File
@@ -16,7 +16,7 @@ const Admin = (() => {
/* ---- transport ------------------------------------------------------- */
// The sign-in behind this page lasts thirty minutes and slides forward only for requests
// The sign-in behind this page lasts twelve hours and slides forward only for requests
// an operator actually caused, so the poll of a tab nobody is reading cannot keep it
// alive. Anything the console does while somebody is working it says so with this
// header; see operatorPresent on the server.
+9 -2
View File
@@ -27,8 +27,15 @@
{{end}}
</div>
<div class="rail-foot">
<span class="dot" id="rail-live" data-tone="idle"></span>
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
<div class="rail-status">
<span class="dot" id="rail-live" data-tone="idle"></span>
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
</div>
<form method="post" action="/admin/logout">
<button class="quiet small rail-logout" type="submit" data-icon="power" aria-label="Log out">
<span>Log out</span>
</button>
</form>
</div>
</aside>
+59 -9
View File
@@ -106,7 +106,7 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
}
// installerSessionExpiring mints a session with a chosen life left, which is the only way
// to reach the renewal window without waiting a quarter of an hour in a test.
// to reach the renewal window without waiting six hours in a test.
func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
t.Helper()
payload := make([]byte, 8+16)
@@ -137,7 +137,7 @@ func renewedCookie(rec *httptest.ResponseRecorder) *http.Cookie {
// The admin sign-in used to be an absolute half hour: an operator was signed out from
// under themselves mid-edit, and the console's poll then reported "invalid admin token"
// with no way back to a login.
// with no way back to a login. A renewed admin session now lasts a full working day.
func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
@@ -158,10 +158,14 @@ func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
if cookie == nil {
t.Fatal("expected a refreshed installer cookie")
}
if cookie.MaxAge != int(adminSessionTTL/time.Second) {
t.Fatalf("renewed admin cookie MaxAge = %d, want %d",
cookie.MaxAge, int(adminSessionTTL/time.Second))
}
follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
follow.AddCookie(cookie)
expires, ok := server.installerSessionExpiry(follow)
if !ok || time.Until(expires) < installerSessionTTL-time.Minute {
if !ok || time.Until(expires) < adminSessionTTL-time.Minute {
t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)",
time.Until(expires), ok)
}
@@ -198,7 +202,7 @@ func TestAdminSessionIsNotRewrittenWhileItIsStillFresh(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
req := adminRequest(server, installerSessionTTL-time.Minute, t)
req := adminRequest(server, adminSessionTTL-time.Minute, t)
req.Header.Set(adminActivityHeader, "1")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -456,12 +460,16 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server.adminRoutes().ServeHTTP(rec, req)
result := rec.Result()
cookies := result.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one admin cookie, got %d", len(cookies))
var cookie *http.Cookie
for _, candidate := range rec.Result().Cookies() {
if candidate.Name == adminCookieName {
cookie = candidate
break
}
}
if cookie == nil {
t.Fatal("admin page did not establish its persistent cookie")
}
cookie := cookies[0]
if cookie.Name != adminCookieName || cookie.Value != "secret" {
t.Fatalf("unexpected admin cookie: %#v", cookie)
}
@@ -473,6 +481,48 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
}
}
func TestAdminPageOffersLogout(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/overview", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `method="post" action="/admin/logout"`) ||
!strings.Contains(rec.Body.String(), ">Log out</span>") {
t.Fatal("admin shell does not offer logout")
}
}
func TestAdminLogoutClearsBothBrowserCookies(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodPost, "https://memby.local/admin/logout", nil)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("logout = %d %q, want 303 to admin gate", rec.Code, rec.Header().Get("Location"))
}
cleared := map[string]*http.Cookie{}
for _, cookie := range rec.Result().Cookies() {
cleared[cookie.Name] = cookie
}
for name, path := range map[string]string{
installerCookieName: "/", adminCookieName: "/admin",
} {
cookie := cleared[name]
if cookie == nil || cookie.MaxAge >= 0 || cookie.Path != path || !cookie.HttpOnly || !cookie.Secure {
t.Fatalf("logout did not clear %s safely: %#v", name, cookie)
}
}
}
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
+27 -15
View File
@@ -17,13 +17,13 @@ import (
const (
installerCookieName = "memby_installer"
installerSessionTTL = 30 * time.Minute
adminSessionTTL = 12 * time.Hour
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// installerRenewWithin is how close to expiry a session must be before an operator's
// own request re-issues it. Half the TTL, so a cookie is rewritten at most once every
// fifteen minutes rather than on every request of a working session.
installerRenewWithin = installerSessionTTL / 2
// adminRenewWithin is how close to expiry a session must be before an operator's own
// request re-issues it. Half the TTL avoids rewriting the cookie on every request.
adminRenewWithin = adminSessionTTL / 2
)
func (s *Server) installerSecret() []byte {
@@ -45,8 +45,12 @@ func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
}
func (s *Server) newInstallerSession() (string, error) {
return s.newBrowserSession(installerSessionTTL)
}
func (s *Server) newBrowserSession(ttl time.Duration) (string, error) {
payload := make([]byte, 8+16)
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(installerSessionTTL).Unix()))
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
if _, err := rand.Read(payload[8:]); err != nil {
return "", err
}
@@ -79,7 +83,7 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
if expires <= now || expires > now+int64(installerSessionTTL/time.Second)+60 {
if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 {
return time.Time{}, false
}
return time.Unix(expires, 0), true
@@ -90,31 +94,35 @@ func (s *Server) validInstallerSession(r *http.Request) bool {
return ok
}
// renewInstallerSession slides a valid session's expiry forward. The TTL was absolute and
// renewAdminSession slides a valid session's expiry forward. The TTL was absolute and
// nothing extended it, so an operator working the admin console was signed out from under
// themselves after thirty minutes and the page's poll became a permanent "invalid admin
// themselves and the page's poll became a permanent "invalid admin
// token" banner with no sign-in to return to. Callers must only reach here for a request
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewInstallerSession(w http.ResponseWriter, r *http.Request) {
func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.installerSessionExpiry(r)
if !ok || time.Until(expires) > installerRenewWithin {
if !ok || time.Until(expires) > adminRenewWithin {
return
}
session, err := s.newInstallerSession()
session, err := s.newBrowserSession(adminSessionTTL)
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
}
s.setInstallerCookie(w, session)
s.setBrowserSessionCookie(w, session, adminSessionTTL)
}
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
s.setBrowserSessionCookie(w, value, installerSessionTTL)
}
func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: installerCookieName,
Value: value,
Path: "/",
MaxAge: int(installerSessionTTL / time.Second),
MaxAge: int(ttl / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
@@ -210,13 +218,17 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
return
}
session, err := s.newInstallerSession()
ttl := installerSessionTTL
if strings.HasPrefix(next, "/admin/") {
ttl = adminSessionTTL
}
session, err := s.newBrowserSession(ttl)
if err != nil {
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
return
}
s.setInstallerCookie(w, session)
s.setBrowserSessionCookie(w, session, ttl)
http.Redirect(w, r, next, http.StatusSeeOther)
}