feat(login): 未绑定 Owner 时说明原因和绑定方式
This commit is contained in:
18 files changed
+579
-20
No files matched your search
@@ -21,6 +21,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
@@ -237,6 +238,9 @@ type API struct {
|
||||
mailOnce sync.Once
|
||||
mailBuckets *bucketSet
|
||||
|
||||
// ownerBound caches the first "an Owner exists" answer (handleOwnerStatus).
|
||||
ownerBound atomic.Bool
|
||||
|
||||
drainInit sync.Once
|
||||
drainClose sync.Once
|
||||
drain chan struct{}
|
||||
@@ -502,6 +506,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "POST", Pattern: "/api/v1/auth/options", Public: true, AuthDoor: true, h: a.handleAuthOptions},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/setup/redeem", Public: true, AuthDoor: true, h: a.handleSetupRedeem},
|
||||
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
|
||||
// Whether an Owner is bound yet: before one is, every login door here is off, and the
|
||||
// sign-in page says so instead of offering them (handlers_auth_owner.go).
|
||||
{Method: "GET", Pattern: "/api/v1/auth/owner-status", Public: true, h: a.handleOwnerStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, AuthDoor: true, h: a.handlePasskeyLoginBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, AuthDoor: true, h: a.handlePasskeyLoginFinish},
|
||||
// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
|
||||
|
||||
@@ -94,6 +94,7 @@ type fakeRepo struct {
|
||||
failRevokeOthers error
|
||||
failMarkReauth error
|
||||
failGetSetting error
|
||||
failAdminExists error // AdminExists fails with it (a store outage)
|
||||
failRedeemSetup error
|
||||
failUserDetail error
|
||||
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
||||
@@ -1106,6 +1107,17 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) AdminExists(_ context.Context) (bool, error) {
|
||||
if f.failAdminExists != nil {
|
||||
return false, f.failAdminExists
|
||||
}
|
||||
for _, u := range f.staff {
|
||||
if u.Role == "admin" || u.Role == "owner" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
|
||||
f.sessions[ns.TokenHash] = &fakeSession{
|
||||
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: ns.CreatedAt, lastSeen: ns.CreatedAt,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Pre-session install-state probe. Until `felis setup` binds an Owner, local sign-in is
|
||||
// off and every login door answers 403 local_auth_disabled, so the sign-in page would
|
||||
// offer four doors that all fail. This Public route lets the page say instead that no
|
||||
// Owner exists yet and how to bind one.
|
||||
//
|
||||
// It discloses one bit: whether the install is still unclaimed. Claiming it needs root
|
||||
// on the host (`felis setup` or the break-glass console) plus a Minecraft join whose
|
||||
// link code is typed into that terminal; no web door works before then, so knowing the
|
||||
// bit gives a remote caller nothing to act on. It must answer while local auth is off,
|
||||
// so unlike its sibling doors it is not gated on local_auth_enabled.
|
||||
//
|
||||
// The first true is cached in API.ownerBound. An Owner is never unbound through the
|
||||
// product, so from then on the probe costs no query; before it, each call is one
|
||||
// indexed LIMIT 1 read. It is not an AuthDoor: the page polls it on every load, and
|
||||
// sharing the doors' per-address bucket would throttle the sign-in that follows.
|
||||
type ownerStatusView struct {
|
||||
OwnerBound bool `json:"owner_bound"`
|
||||
}
|
||||
|
||||
// handleOwnerStatus reports whether any staff account exists. A store failure is a 503,
|
||||
// so the page falls back to its normal doors rather than claiming the install is unbound.
|
||||
func (a *API) handleOwnerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ownerBound.Load() {
|
||||
writeJSON(w, http.StatusOK, ownerStatusView{OwnerBound: true})
|
||||
return
|
||||
}
|
||||
bound, err := a.Repo.AdminExists(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("owner status: %v", err)
|
||||
writeError(w, r, errAuthUnavailable)
|
||||
return
|
||||
}
|
||||
if bound {
|
||||
a.ownerBound.Store(true)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ownerStatusView{OwnerBound: bound})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const ownerStatusPath = "/api/v1/auth/owner-status"
|
||||
|
||||
func ownerBoundOf(t *testing.T, w *httptest.ResponseRecorder) bool {
|
||||
t.Helper()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
OwnerBound *bool `json:"owner_bound"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil || v.OwnerBound == nil {
|
||||
t.Fatalf("body = %s, want {\"owner_bound\": bool} (err %v)", w.Body.String(), err)
|
||||
}
|
||||
return *v.OwnerBound
|
||||
}
|
||||
|
||||
// TestOwnerStatusReportsAnUnclaimedInstall pins what the sign-in page reads: false before
|
||||
// any staff account exists, true once one does, and a player account alone is not an
|
||||
// Owner. It answers with local auth still off, which is the state it exists to explain.
|
||||
func TestOwnerStatusReportsAnUnclaimedInstall(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
if localAuthEnabled(t.Context(), repo) {
|
||||
t.Fatal("precondition: a fresh fake must have local auth off")
|
||||
}
|
||||
if ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an install with no accounts reports an Owner")
|
||||
}
|
||||
|
||||
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player", Role: "user"}
|
||||
if ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("a player account alone reports an Owner")
|
||||
}
|
||||
|
||||
repo.staff["boss"] = &StaffUser{ID: "o1", Username: "boss", Role: "owner"}
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an install with an Owner reports none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerStatusCachesTheBoundAnswer pins that once an Owner is seen the probe stops
|
||||
// querying: a store that then fails still gets the cached true.
|
||||
func TestOwnerStatusCachesTheBoundAnswer(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.staff["boss"] = &StaffUser{ID: "o1", Username: "boss", Role: "owner"}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Fatal("an install with an Owner reports none")
|
||||
}
|
||||
repo.failAdminExists = errors.New("store down")
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("the bound answer was not cached")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerStatusStoreFailure pins that an outage is a 503, never a false "no Owner":
|
||||
// the page must fall back to its doors rather than tell a claimed install to run setup.
|
||||
// An unbound answer is not cached either, so the next call reads the store again.
|
||||
func TestOwnerStatusStoreFailure(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
repo.failAdminExists = errors.New("store down")
|
||||
w := do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := decodeErr(t, w); got != "auth_unavailable" {
|
||||
t.Errorf("error = %q, want auth_unavailable", got)
|
||||
}
|
||||
|
||||
repo.failAdminExists = nil
|
||||
if ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an install with no accounts reports an Owner")
|
||||
}
|
||||
repo.staff["boss"] = &StaffUser{ID: "o1", Username: "boss", Role: "owner"}
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an unbound answer was cached past the Owner's arrival")
|
||||
}
|
||||
}
|
||||
@@ -1334,7 +1334,6 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse
|
||||
// break-glass console's bootstrap-vs-recovery switch: false means the typed
|
||||
// credential mints the first Owner (no prior identity to verify against), true
|
||||
// means the operator must identify against an existing staff account for accountability.
|
||||
// It is not on the Repo interface because only the break-glass CLI consults it.
|
||||
func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) {
|
||||
const q = `SELECT 1 FROM users WHERE role IN ('admin', 'owner') LIMIT 1`
|
||||
var one int
|
||||
|
||||
@@ -702,6 +702,10 @@ type Repo interface {
|
||||
// re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is
|
||||
// promoted. The account is passwordless by design.
|
||||
UpsertOwner(ctx context.Context, id, username, email string) error
|
||||
// AdminExists reports whether any staff account (admin or owner) exists. It is false
|
||||
// only on an install `felis setup` has not bound an Owner on yet, where local sign-in
|
||||
// is still off: the sign-in page reads it to say so (handleOwnerStatus).
|
||||
AdminExists(ctx context.Context) (bool, error)
|
||||
// CreateSession records a minted session (spec §B sessions). Only the hash of
|
||||
// the cookie is stored, mirroring tokens, so a database read never yields a
|
||||
// usable cookie. The session counts as seen at creation.
|
||||
|
||||
Reference in new issue
Block a user