http: requireRole middleware + 403 forbidden page
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
stdhttp "net/http"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,3 +27,61 @@ func roleAtLeast(have, min store.Role) bool {
|
|||||||
}
|
}
|
||||||
return h >= m
|
return h >= m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireRole returns chi middleware that 403s any request whose
|
||||||
|
// session-resolved user doesn't meet the minimum role. Unauthenticated
|
||||||
|
// requests return 401 (JSON) or 303 → /login (HTML) so the caller
|
||||||
|
// gets a usable error rather than a confusing 403.
|
||||||
|
//
|
||||||
|
// The middleware re-reads the user row on every request — by the time
|
||||||
|
// you read this you might be tempted to cache; don't. SQLite's WAL
|
||||||
|
// makes the lookup cheap and admin-driven changes (disable, role
|
||||||
|
// change) need to land immediately.
|
||||||
|
func (s *Server) requireRole(min store.Role) func(stdhttp.Handler) stdhttp.Handler {
|
||||||
|
return func(next stdhttp.Handler) stdhttp.Handler {
|
||||||
|
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
u, ok := s.requireUser(r)
|
||||||
|
if !ok {
|
||||||
|
if isAPIPath(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !roleAtLeast(u.Role, min) {
|
||||||
|
if isAPIPath(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusForbidden, "insufficient_role", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderForbiddenHTML(s, w, r, u, min)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAPIPath reports whether the path lives under /api/. Lets one
|
||||||
|
// middleware return JSON or HTML appropriately without two near-
|
||||||
|
// identical wrappers.
|
||||||
|
func isAPIPath(r *stdhttp.Request) bool {
|
||||||
|
p := r.URL.Path
|
||||||
|
return len(p) >= 5 && p[:5] == "/api/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderForbiddenHTML emits a small "you don't have permission"
|
||||||
|
// panel inside the chrome so the user keeps their nav and can
|
||||||
|
// move away to a page they can see.
|
||||||
|
func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) {
|
||||||
|
w.WriteHeader(stdhttp.StatusForbidden)
|
||||||
|
view := s.baseView(r, &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)})
|
||||||
|
view.Title = "Forbidden · restic-manager"
|
||||||
|
view.Page = struct {
|
||||||
|
Required string
|
||||||
|
Have string
|
||||||
|
}{Required: string(min), Have: string(u.Role)}
|
||||||
|
if err := s.deps.UI.Render(w, "forbidden", view); err != nil {
|
||||||
|
_, _ = w.Write([]byte("403 Forbidden — your role does not permit this page."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
stdhttp "net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
@@ -31,3 +34,63 @@ func TestRoleAtLeast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequireRoleViewerAdmits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, _ := newTestServer(t, false)
|
||||||
|
uid := makeUser(t, srv, "viewer1", store.RoleViewer)
|
||||||
|
cookie := loginAs(t, srv, uid)
|
||||||
|
|
||||||
|
mid := srv.requireRole(store.RoleViewer)
|
||||||
|
h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
|
||||||
|
w.WriteHeader(stdhttp.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != stdhttp.StatusOK {
|
||||||
|
t.Errorf("status: got %d want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireRoleViewerRejectedFromOperator(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, _ := newTestServer(t, false)
|
||||||
|
uid := makeUser(t, srv, "viewer2", store.RoleViewer)
|
||||||
|
cookie := loginAs(t, srv, uid)
|
||||||
|
|
||||||
|
mid := srv.requireRole(store.RoleOperator)
|
||||||
|
h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
|
||||||
|
w.WriteHeader(stdhttp.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != stdhttp.StatusForbidden {
|
||||||
|
t.Errorf("status: got %d want 403", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "insufficient_role") {
|
||||||
|
t.Errorf("body: got %q", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, _ := newTestServer(t, false)
|
||||||
|
|
||||||
|
mid := srv.requireRole(store.RoleViewer)
|
||||||
|
h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
|
||||||
|
w.WriteHeader(stdhttp.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != stdhttp.StatusUnauthorized {
|
||||||
|
t.Errorf("status: got %d want 401", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "title"}}Forbidden · restic-manager{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[1280px] mx-auto px-8 pb-14">
|
||||||
|
<div class="crumbs pt-6">
|
||||||
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||||
|
<span class="text-ink-mid">forbidden</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel mt-8 rounded-[7px] p-8 max-w-[640px]"
|
||||||
|
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
|
||||||
|
<div class="text-[14px] font-medium text-bad mb-2">403 — Insufficient role</div>
|
||||||
|
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
|
||||||
|
Your role (<span class="mono">{{$page.Have}}</span>) does not permit
|
||||||
|
this page (<span class="mono">{{$page.Required}}</span> required).
|
||||||
|
Ask your administrator if you need access.
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary mt-5">Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user