0.2.40
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user