feat: initialize panel access before linking Minecraft accounts
Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials. Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
This commit is contained in:
59 files changed
+1702
-1564
No files matched your search
@@ -643,6 +643,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// authenticated operation.
|
||||
{Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
|
||||
{Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, h: a.handleLinkVerify},
|
||||
// Staff can designate their own game identity after panel setup. Players
|
||||
// retain the in-game proof flow above.
|
||||
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
||||
{Method: "GET", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLookupProfile},
|
||||
{Method: "POST", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLinkProfile},
|
||||
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
|
||||
// one-time code for the caller's chosen address, /verify redeems it and flips
|
||||
// email_verified. App-tier like the link routes — proving control of your own
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type linkedProfile struct {
|
||||
Source string `json:"source"`
|
||||
Name string `json:"name"`
|
||||
ProfileUUID string `json:"profile_uuid"`
|
||||
MCUUID string `json:"mc_uuid"`
|
||||
AuthSource string `json:"auth_source"`
|
||||
}
|
||||
|
||||
func profileAPIBase(src AuthSource) string {
|
||||
if src.APIURL != "" {
|
||||
return strings.TrimRight(src.APIURL, "/")
|
||||
}
|
||||
const suffix = "/sessionserver/session/minecraft/hasJoined"
|
||||
if strings.HasSuffix(src.URL, suffix) {
|
||||
return strings.TrimSuffix(src.URL, suffix)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
|
||||
type sourceView struct {
|
||||
Tag string `json:"tag"`
|
||||
LookupAvailable bool `json:"lookup_available"`
|
||||
}
|
||||
sources := make([]sourceView, 0, len(a.AuthSources))
|
||||
for _, src := range a.AuthSources {
|
||||
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
|
||||
}
|
||||
|
||||
func (a *API) handleLookupProfile(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
profile, err := a.lookupProfile(r.Context(), q.Get("source"), q.Get("profile"))
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// handleLinkProfile is a staff designation, not proof of game-account ownership.
|
||||
// The user must already have panel authority and a fresh login factor. A client
|
||||
// supplies only the selected source and native role UUID; mapping and target user
|
||||
// are determined on the server.
|
||||
func (a *API) handleLinkProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p := principalFromContext(r.Context())
|
||||
if !a.requireReauth(w, r, p) {
|
||||
return
|
||||
}
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
ProfileUUID string `json:"profile_uuid"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if _, err := uuid.Parse(req.ProfileUUID); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "profile_uuid must be a role UUID"))
|
||||
return
|
||||
}
|
||||
profile, err := a.lookupProfile(r.Context(), req.Source, req.ProfileUUID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
err = a.Repo.LinkAccount(r.Context(), p.UserID, profile.MCUUID, profile.AuthSource)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
writeError(w, r, newError(http.StatusConflict, "already_linked", "that Minecraft role is linked to another user"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "account.link_profile", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": profile.MCUUID, "auth_source": profile.AuthSource})
|
||||
}
|
||||
|
||||
func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedProfile, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
id, idErr := uuid.Parse(input)
|
||||
if idErr != nil && !mcUsernameRe.MatchString(input) {
|
||||
return nil, newError(http.StatusBadRequest, "bad_request", "provide a Minecraft role name or UUID")
|
||||
}
|
||||
var src AuthSource
|
||||
found := false
|
||||
for _, candidate := range a.AuthSources {
|
||||
if candidate.Tag == source {
|
||||
src, found = candidate, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, newError(http.StatusBadRequest, "auth_source_unknown", "select a configured authentication source")
|
||||
}
|
||||
var target, method string
|
||||
var body io.Reader
|
||||
if src.Identity {
|
||||
method = http.MethodGet
|
||||
if idErr == nil {
|
||||
target = strings.TrimSuffix(src.URL, "/hasJoined") + "/profile/" + strings.ReplaceAll(id.String(), "-", "")
|
||||
} else {
|
||||
target = mojangProfileAPI + url.PathEscape(input)
|
||||
}
|
||||
} else {
|
||||
base := profileAPIBase(src)
|
||||
if base == "" {
|
||||
return nil, newError(http.StatusBadRequest, "auth_source_lookup_unsupported", "this source needs api_url for role lookup; game-code linking is still available")
|
||||
}
|
||||
if idErr == nil {
|
||||
method, target = http.MethodGet, base+"/sessionserver/session/minecraft/profile/"+strings.ReplaceAll(id.String(), "-", "")
|
||||
} else {
|
||||
method, target = http.MethodPost, base+"/api/profiles/minecraft"
|
||||
encoded, _ := json.Marshal([]string{input})
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Use the same bounded, redirect-free client as game authentication.
|
||||
resp, err := authHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source is unavailable")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
|
||||
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<16))
|
||||
var profile sessionProfile
|
||||
if method == http.MethodPost {
|
||||
var profiles []sessionProfile
|
||||
if err := decoder.Decode(&profiles); err != nil {
|
||||
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
|
||||
}
|
||||
for _, candidate := range profiles {
|
||||
if strings.EqualFold(candidate.Name, input) {
|
||||
profile = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if profile.ID == "" {
|
||||
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
|
||||
}
|
||||
} else if err := decoder.Decode(&profile); err != nil {
|
||||
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
|
||||
}
|
||||
profileID, err := uuid.Parse(profile.ID)
|
||||
if err != nil || !mcUsernameRe.MatchString(profile.Name) ||
|
||||
(idErr == nil && profileID != id) || (idErr != nil && !strings.EqualFold(profile.Name, input)) {
|
||||
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "the source returned a mismatched or invalid role")
|
||||
}
|
||||
canonical, err := canonicalProfileUUID(src, profile.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authSource := authSourceThirdParty
|
||||
if src.Identity {
|
||||
authSource = authSourceMojang
|
||||
}
|
||||
return &linkedProfile{Source: src.Tag, Name: profile.Name, ProfileUUID: profile.ID, MCUUID: canonical.String(), AuthSource: authSource}, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestStaffProfileDesignation(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
stubMojangNames(t)
|
||||
upstreamCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/profiles/minecraft":
|
||||
var names []string
|
||||
if err := json.NewDecoder(r.Body).Decode(&names); err != nil || len(names) != 1 || names[0] != "LemonMiaow" {
|
||||
t.Errorf("lookup names = %v err = %v", names, err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":"` + native + `","name":"LemonMiaow"}]`))
|
||||
case strings.HasPrefix(r.URL.Path, "/sessionserver/session/minecraft/profile/"):
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/hasJoined"):
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "owner", Username: "owner", Role: "owner"})
|
||||
a := newTestAPI(repo, newFakeCluster())
|
||||
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}
|
||||
a.External = staticExternal{p: p}
|
||||
src := AuthSource{Tag: "littleskin", Prefix: "LS", URL: server.URL + "/sessionserver/session/minecraft/hasJoined"}
|
||||
a.AuthSources = []AuthSource{src, {Tag: "custom", URL: server.URL + "/custom-check"}}
|
||||
h := a.ExternalHandler()
|
||||
lookup := func(source, profile string) *httptest.ResponseRecorder {
|
||||
return do(h, "GET", "/api/v1/account/link/profile?"+url.Values{"source": {source}, "profile": {profile}}.Encode(), "", nil)
|
||||
}
|
||||
w := do(h, "GET", "/api/v1/account/link/sources", "", nil)
|
||||
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), server.URL) {
|
||||
t.Fatalf("sources = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = lookup("littleskin", "LemonMiaow")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("lookup = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var profile linkedProfile
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, _ := canonicalProfileUUID(src, native)
|
||||
if profile.MCUUID != canonical.String() || profile.AuthSource != "thirdparty" || len(repo.links) != 0 {
|
||||
t.Fatalf("preview = %+v links = %v", profile, repo.links)
|
||||
}
|
||||
game := httptest.NewRecorder()
|
||||
a.handleHasJoined(game, httptest.NewRequest("GET", "/session/minecraft/hasJoined?username=LemonMiaow&serverId=abc123", nil))
|
||||
var authenticated sessionProfile
|
||||
if err := json.Unmarshal(game.Body.Bytes(), &authenticated); err != nil || game.Code != http.StatusOK || authenticated.ID != strings.ReplaceAll(profile.MCUUID, "-", "") {
|
||||
t.Fatalf("preview differs from game identity: %d %s, err = %v", game.Code, game.Body.String(), err)
|
||||
}
|
||||
body := `{"source":"littleskin","profile_uuid":"` + native + `"}`
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusOK || repo.links[canonical.String()] != "owner" {
|
||||
t.Fatalf("bind = %d %s links = %v", w.Code, w.Body.String(), repo.links)
|
||||
}
|
||||
// Server-side designation is idempotent and never consumes game link codes.
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusOK || len(repo.links) != 1 {
|
||||
t.Fatalf("repeat = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
repo.links[canonical.String()] = "another-user"
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusConflict || repo.links[canonical.String()] != "another-user" {
|
||||
t.Fatal("designation overwrote another user")
|
||||
}
|
||||
w = lookup("custom", "LemonMiaow")
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "auth_source_lookup_unsupported" {
|
||||
t.Fatalf("custom = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
before := upstreamCalls
|
||||
w = lookup("unknown", "LemonMiaow")
|
||||
if w.Code != http.StatusBadRequest || upstreamCalls != before {
|
||||
t.Fatal("unknown source queried an upstream")
|
||||
}
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", `{"source":"littleskin","profile_uuid":"`+native+`","user_id":"victim"}`, jsonHeader)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatal("caller can select another account")
|
||||
}
|
||||
// A local staff session must prove its factor before designating a role.
|
||||
p.ViaSession = true
|
||||
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
|
||||
before = upstreamCalls
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" || upstreamCalls != before {
|
||||
t.Fatalf("stale staff session = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
p.ReauthAt = a.now()
|
||||
repo.links[canonical.String()] = "owner"
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("proven staff session = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// A player's session never reaches either lookup or designation.
|
||||
p.Role = "user"
|
||||
before = upstreamCalls
|
||||
for _, w := range []*httptest.ResponseRecorder{lookup("littleskin", "LemonMiaow"), do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)} {
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("player route = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if upstreamCalls != before {
|
||||
t.Fatal("player reached role lookup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLookupRejectsInvalidResponses(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
status, want int
|
||||
}{
|
||||
{"missing", "", 204, 404},
|
||||
{"unavailable", "", 503, 503},
|
||||
{"invalid JSON", "broken", 200, 502},
|
||||
{"different UUID", `{"id":"223456781234423482341234567890ab","name":"LemonMiaow"}`, 200, 502},
|
||||
{"invalid name", `{"id":"` + native + `","name":"invalid name"}`, 200, 502},
|
||||
{"redirect", "", 302, 503},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
a.AuthSources = []AuthSource{{Tag: "test", APIURL: server.URL}}
|
||||
_, err := a.lookupProfile(t.Context(), "test", native)
|
||||
var apiErr *apiError
|
||||
if !errors.As(err, &apiErr) || apiErr.status != tc.want {
|
||||
t.Fatalf("lookup err = %v, want %d", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialProfileUUIDIsPreserved(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
a.AuthSources = []AuthSource{{Tag: "mojang", Identity: true, URL: server.URL + "/session/minecraft/hasJoined"}}
|
||||
profile, err := a.lookupProfile(t.Context(), "mojang", native)
|
||||
if err != nil || profile.MCUUID != uuid.MustParse(native).String() || profile.AuthSource != "mojang" {
|
||||
t.Fatalf("profile = %+v err = %v", profile, err)
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Pre-session install-state probe. Until `felis setup` binds an Owner, local sign-in is
|
||||
// Pre-session install-state probe. Until `felis setup` creates 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
|
||||
// on the host (`felis setup` or the break-glass console); 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.
|
||||
//
|
||||
|
||||
@@ -78,6 +78,7 @@ type AuthSource struct {
|
||||
Tag string
|
||||
Prefix string
|
||||
URL string
|
||||
APIURL string // optional Yggdrasil API root for role lookup
|
||||
Identity bool
|
||||
}
|
||||
|
||||
@@ -145,15 +146,12 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
|
||||
// nor onto another source's. resolveHasJoined has already screened both shapes and
|
||||
// skipped unusable ones as failed; the two guards below are the last line before
|
||||
// anything leaves, kept even though nothing reaches them.
|
||||
var canonical uuid.UUID
|
||||
if src.Identity {
|
||||
id, err := uuid.Parse(prof.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
canonical = id
|
||||
} else {
|
||||
canonical, err := canonicalProfileUUID(src, prof.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if !src.Identity {
|
||||
// A third-party source is untrusted input, its name included: nothing stops a
|
||||
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
|
||||
// characters, all of which must not be relayed straight into the proxy's player
|
||||
@@ -162,7 +160,6 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
|
||||
|
||||
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
|
||||
// already keeps the two apart as identities, but the proxy's player registry is
|
||||
@@ -224,6 +221,15 @@ func prefixedName(prefix, name string) string {
|
||||
return p + name
|
||||
}
|
||||
|
||||
// canonicalProfileUUID is shared by game login and staff-initiated role binding.
|
||||
// Keep the source's native ID byte-for-byte: existing third-party identities use it.
|
||||
func canonicalProfileUUID(src AuthSource, nativeID string) (uuid.UUID, error) {
|
||||
if src.Identity {
|
||||
return uuid.Parse(nativeID)
|
||||
}
|
||||
return uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+nativeID)), nil
|
||||
}
|
||||
|
||||
// mojangProfileAPI answers the one question that decides a rename: is this username
|
||||
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
|
||||
// instead of the real Mojang.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
|
||||
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` host bootstrap
|
||||
// flow mints a one-time token and prints a URL like:
|
||||
//
|
||||
// https://op.console.<root>/setup?token=<raw>
|
||||
@@ -58,7 +58,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Hash the raw token — only the hash is stored (mirroring session cookies and
|
||||
// setup token creation in performSetupMCBind).
|
||||
// setup token creation in performSetupOwner).
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
tokenHash := hex.EncodeToString(sum[:])
|
||||
|
||||
|
||||
+34
-59
@@ -264,86 +264,61 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
|
||||
return userID, mcUUID, authSource, nil
|
||||
}
|
||||
|
||||
// CompleteOwnerSetup consumes an in-game link code, creates-or-promotes the bound
|
||||
// account to the passwordless Owner (role='owner'), enables local auth, and stores
|
||||
// the one-time first-login token in one transaction. It is the `felis setup`
|
||||
// MC-bind path: the operator enters limbo, runs /link, and types the code here.
|
||||
// Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game
|
||||
// login can never self-elevate — this DELIBERATELY elevates: an unlinked UUID is
|
||||
// born directly as staff, and an already-linked account (player OR staff) is
|
||||
// promoted in place, preserving its id so any live sessions and its username
|
||||
// survive. The elevation is gated by the caller's local-root break-glass
|
||||
// authority, not by anything in-band. Returns the Owner's (userID, mcUUID,
|
||||
// authSource); an absent or expired code is ErrLinkCodeInvalid and consumes
|
||||
// nothing. Any failure in the auth-toggle or token writes rolls the elevation and
|
||||
// code consumption back, leaving the operator able to retry setup.
|
||||
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
|
||||
tokenHash string, tokenExpiresAt time.Time) (string, string, string, error) {
|
||||
// CompleteOwnerSetup creates the first Owner and a one-time panel login under
|
||||
// host-root authority. An unfinished setup renews the link without resetting the
|
||||
// account; an Owner with a login factor is left untouched (ErrConflict).
|
||||
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time,
|
||||
tokenHash string, tokenExpiresAt time.Time) (string, string, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||
|
||||
var mcUUID, authSource string
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
|
||||
code, now).Scan(&mcUUID, &authSource); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", "", "", ErrLinkCodeInvalid
|
||||
case err != nil:
|
||||
return "", "", "", err
|
||||
// Serialize first-run creation as well as link renewal, including the case
|
||||
// where there is no user row to lock yet.
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('felis.owner_setup'))`); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh
|
||||
// staff row (role='owner') with a uuid-derived username; an already-linked
|
||||
// account is promoted to role='owner' in place (idempotent when it already is),
|
||||
// keeping its id and username. Setup elevates on purpose, so there is no staff
|
||||
// refusal here — that guard belongs to the player path only.
|
||||
userID := newUserID
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
|
||||
userID, username := newUserID, "owner"
|
||||
var onboarded bool
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT u.id, u.username, u.email_verified OR EXISTS (
|
||||
SELECT 1 FROM webauthn_credentials c WHERE c.user_id = u.id)
|
||||
FROM users u WHERE u.role IN ('owner', 'admin') AND u.deleted_at IS NULL
|
||||
ORDER BY (u.role = 'owner') DESC, u.created_at, u.id LIMIT 1 FOR UPDATE`,
|
||||
).Scan(&userID, &username, &onboarded)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, role, created_at) VALUES ($1, $2, 'owner', $3)`,
|
||||
newUserID, mcUUID, now); err != nil {
|
||||
return "", "", "", fmt.Errorf("create owner: %w", err)
|
||||
newUserID, username, now); err != nil {
|
||||
return "", "", fmt.Errorf("create owner: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO account_links (user_id, mc_uuid, auth_source, verified_at) VALUES ($1, $2, $3, $4)`,
|
||||
newUserID, mcUUID, authSource, now); err != nil {
|
||||
return "", "", "", fmt.Errorf("write account link: %w", err)
|
||||
}
|
||||
userID = newUserID
|
||||
case err != nil:
|
||||
return "", "", "", err
|
||||
default:
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET role = 'owner' WHERE id = $1`, userID); err != nil {
|
||||
return "", "", "", fmt.Errorf("promote owner: %w", err)
|
||||
}
|
||||
return "", "", err
|
||||
case onboarded:
|
||||
return userID, username, ErrConflict
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO platform_settings (key, value, updated_at) VALUES ($1, $2::jsonb, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
|
||||
LocalAuthEnabledKey, "true", now); err != nil {
|
||||
return "", "", "", fmt.Errorf("enable local auth: %w", err)
|
||||
return "", "", fmt.Errorf("enable local auth: %w", err)
|
||||
}
|
||||
// A renewed link replaces any unused links for this account.
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM setup_tokens WHERE user_id = $1`, userID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO setup_tokens (token_hash, user_id, expires_at, created_at) VALUES ($1, $2, $3, $4)`,
|
||||
tokenHash, userID, tokenExpiresAt, now); err != nil {
|
||||
return "", "", "", fmt.Errorf("mint setup token: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
|
||||
return "", "", "", fmt.Errorf("consume link code: %w", err)
|
||||
return "", "", fmt.Errorf("mint setup token: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", "", "", err
|
||||
return "", "", err
|
||||
}
|
||||
return userID, mcUUID, authSource, nil
|
||||
return userID, username, nil
|
||||
}
|
||||
|
||||
// QuotaCheck reports whether accepting a server with resource spec `incoming`
|
||||
@@ -3008,8 +2983,8 @@ func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now tim
|
||||
}
|
||||
|
||||
// CreateSetupToken persists a one-time first-web-login token, storing only its
|
||||
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind
|
||||
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
|
||||
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-creation
|
||||
// path uses CompleteOwnerSetup so Owner creation, local auth, and this token
|
||||
// commit atomically; this lower-level helper remains for callers that already
|
||||
// established the user. The token is redeemed exactly once by RedeemSetupToken.
|
||||
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
||||
|
||||
Reference in new issue
Block a user