feat(login): 未绑定 Owner 时说明原因和绑定方式

This commit is contained in:
Lemon-miaow committed 2026-10-02 19:38:36 +08:00
1 parent 9139c5bd35
commit 2e4f118939
18 files changed
+579 -20

No files matched your search

+7
View File
@@ -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
+12
View File
@@ -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,
+44
View File
@@ -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})
}
+89
View File
@@ -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")
}
}
-1
View File
@@ -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
+4
View File
@@ -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.