http: requireRole middleware + 403 forbidden page
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
@@ -24,3 +27,61 @@ func roleAtLeast(have, min store.Role) bool {
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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