Phase 4 — P4-03/04: RBAC + user management #14
@@ -59,6 +59,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req
|
||||
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
if u.DisabledAt != nil {
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
|
||||
@@ -152,6 +152,12 @@ func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) {
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if u.DisabledAt != nil {
|
||||
// Disabled mid-session — kill the session and reject the
|
||||
// request as if it were unauthenticated.
|
||||
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stdhttp "net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
@@ -95,6 +98,50 @@ func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoleRejectsDisabledMidSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, urlBase := newTestServer(t, false)
|
||||
uid := makeUser(t, srv, "victim", store.RoleOperator)
|
||||
cookie := loginAs(t, srv, uid)
|
||||
|
||||
// Disable the user *while their session is still valid*.
|
||||
if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
|
||||
req, _ := stdhttp.NewRequest("GET", urlBase+"/api/hosts", nil)
|
||||
req.AddCookie(cookie)
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusUnauthorized {
|
||||
t.Errorf("status: got %d want 401", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsDisabledUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, urlBase := newTestServer(t, false)
|
||||
uid := makeUser(t, srv, "disabled1", store.RoleOperator)
|
||||
if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"username": "disabled1", "password": "test-password",
|
||||
})
|
||||
res, err := stdhttp.Post(urlBase+"/api/auth/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusUnauthorized {
|
||||
t.Errorf("status: got %d want 401", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminBandRejectsOperator(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This test will start asserting 403 once Task B4 mounts /api/users
|
||||
|
||||
@@ -66,6 +66,10 @@ func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if u.DisabledAt != nil {
|
||||
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
||||
return nil, nil
|
||||
}
|
||||
return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user