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 {
|
||||
|
||||
@@ -62,6 +62,8 @@ type AuthSourceConfig struct {
|
||||
Tag string `toml:"tag"`
|
||||
Prefix string `toml:"prefix"`
|
||||
URL string `toml:"url"`
|
||||
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
|
||||
APIURL string `toml:"api_url"`
|
||||
}
|
||||
|
||||
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
||||
@@ -149,6 +151,9 @@ type VelocityConfig struct {
|
||||
// FELIS_GAME_PORT). The panel adds it to the server addresses players copy
|
||||
// when it is not Minecraft's default; 0 means that default, 25565.
|
||||
GamePort int `toml:"game_port"`
|
||||
// GameVersion is the login/lobby protocol built by bootstrap. Empty means
|
||||
// unknown for custom images; the panel must not guess a client version.
|
||||
GameVersion string `toml:"game_version"`
|
||||
}
|
||||
|
||||
// AuthConfig is the [auth] table: the two privileged faces and the Cloudflare
|
||||
@@ -721,6 +726,11 @@ func (c *Config) validateAuthSources() error {
|
||||
if problem := hasJoinedURLProblem(s.URL); problem != "" {
|
||||
return fmt.Errorf("config: [[auth_source]] %q url %q %s", s.Tag, s.URL, problem)
|
||||
}
|
||||
if s.APIURL != "" {
|
||||
if problem := hasJoinedURLProblem(s.APIURL); problem != "" {
|
||||
return fmt.Errorf("config: [[auth_source]] %q api_url %q %s", s.Tag, s.APIURL, problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -501,6 +501,28 @@ func TestLoadRejectsUnqueryableAuthSourceURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourceProfileAPIURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
url string
|
||||
valid bool
|
||||
}{
|
||||
{"https://ygg.example.net/api/yggdrasil", true},
|
||||
{"http://127.0.0.1:8080/ygg", true},
|
||||
{"ygg.example.net/api", false},
|
||||
{"http://ygg.example.net/api", false},
|
||||
{"https://ygg.example.net/api?token=x", false},
|
||||
} {
|
||||
cfg, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \"https://ygg.example.net/custom-check\"\napi_url = \""+tc.url+"\"\n"))
|
||||
if tc.valid {
|
||||
if err != nil || cfg.AuthSources[0].APIURL != tc.url {
|
||||
t.Fatalf("api_url %q = %v, %v", tc.url, cfg, err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), "api_url") {
|
||||
t.Fatalf("invalid api_url %q: %v", tc.url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A source reached over plaintext can be answered by anyone on the path, who can then log in
|
||||
// as any player of that source. Only a same-host or private-network root may skip TLS, and
|
||||
// that is decided on the literal host, since nothing is resolved at load time.
|
||||
|
||||
@@ -26,8 +26,9 @@ type runtimeConfig struct {
|
||||
PanelHostname string `json:"panelHostname,omitempty"`
|
||||
AdminHostname string `json:"adminHostname,omitempty"`
|
||||
// GamePort is the public Minecraft port, absent when it is the default 25565.
|
||||
GamePort int `json:"gamePort,omitempty"`
|
||||
Build buildInfo `json:"build"`
|
||||
GamePort int `json:"gamePort,omitempty"`
|
||||
GameVersion string `json:"gameVersion,omitempty"`
|
||||
Build buildInfo `json:"build"`
|
||||
}
|
||||
|
||||
// buildInfo is the resolved build stamp the panel renders in its version badge.
|
||||
@@ -113,7 +114,7 @@ func parseBuildVersion(raw string) buildInfo {
|
||||
// right surface (player console vs SysAdmin console) without a rebuild. gamePort
|
||||
// is the public Minecraft port ([velocity] game_port), which the SPA appends to
|
||||
// the server addresses players copy; 0 or 25565 leaves them bare.
|
||||
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string, distributed ...bool) http.Handler {
|
||||
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, gameVersion, version string, distributed ...bool) http.Handler {
|
||||
files, err := fs.Sub(static, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -125,6 +126,7 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort
|
||||
panelHostname: panelHost,
|
||||
adminHostname: adminHost,
|
||||
gamePort: publicGamePort(gamePort),
|
||||
gameVersion: gameVersion,
|
||||
build: parseBuildVersion(version),
|
||||
files: files,
|
||||
fileServer: http.FileServer(http.FS(files)),
|
||||
@@ -151,6 +153,7 @@ type handler struct {
|
||||
panelHostname string
|
||||
adminHostname string
|
||||
gamePort int
|
||||
gameVersion string
|
||||
build buildInfo
|
||||
files fs.FS
|
||||
fileServer http.Handler
|
||||
@@ -231,6 +234,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
PanelHostname: h.panelHostname,
|
||||
AdminHostname: h.adminHostname,
|
||||
GamePort: h.gamePort,
|
||||
GameVersion: h.gameVersion,
|
||||
Build: h.build,
|
||||
})
|
||||
case h.hasStaticFile(r.URL.Path):
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
|
||||
}
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
})
|
||||
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "v1.2.3")
|
||||
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "26.3", "v1.2.3")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
@@ -41,7 +41,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" {
|
||||
if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" || cfg.GameVersion != "26.3" {
|
||||
t.Fatalf("config = %+v", cfg)
|
||||
}
|
||||
// The tiering plumbing surfaces the two console hostnames so one bundle can
|
||||
@@ -69,7 +69,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
|
||||
// /config.json carries it, except when a bare hostname already reaches the proxy.
|
||||
func TestHandlerPublishesNonDefaultGamePort(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want int }{{0, 0}, {25565, 0}, {25570, 25570}} {
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "v1.2.3")
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "", "v1.2.3")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config.json", nil))
|
||||
var cfg map[string]any
|
||||
@@ -115,7 +115,7 @@ func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
|
||||
// its hash (and nothing else inline), cannot be framed, and cache by name:
|
||||
// hashed assets forever, the page itself never without revalidation.
|
||||
func TestHandlerSetsPageSecurityAndCacheHeaders(t *testing.T) {
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "v1.2.3")
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "26.3", "v1.2.3")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
// Through the tunnel: TLS to the origin as well, the edge's scheme in XFP.
|
||||
|
||||
@@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler {
|
||||
api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
})
|
||||
return Handler(api, "example.test", "", "", 0, "")
|
||||
return Handler(api, "example.test", "", "", 0, "", "")
|
||||
}
|
||||
|
||||
func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) {
|
||||
|
||||
@@ -126,18 +126,23 @@ func TestLinkWritesStampTheAPIClock(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("owner setup", func(t *testing.T) {
|
||||
mc := testUUID(t)
|
||||
r, setupDB := setupRepository(t)
|
||||
id := "usr-clk-owner-" + suffix(t)
|
||||
tok := "tok-clk-" + suffix(t)
|
||||
if got, _, _, err := repo.CompleteOwnerSetup(ctx, id, code(mc), now, tok, now.Add(time.Hour)); err != nil || got != id {
|
||||
t.Fatalf("CompleteOwnerSetup = %q, %v; want %s", got, err, id)
|
||||
if got, _, err := r.CompleteOwnerSetup(ctx, id, now, tok, now.Add(time.Hour)); err != nil || got != id {
|
||||
t.Fatalf("setup = %q, %v", got, err)
|
||||
}
|
||||
for _, query := range []string{
|
||||
`SELECT created_at FROM users WHERE id = '` + id + `'`,
|
||||
`SELECT created_at FROM setup_tokens WHERE token_hash = '` + tok + `'`,
|
||||
`SELECT updated_at FROM platform_settings WHERE key = '` + api.LocalAuthEnabledKey + `'`,
|
||||
} {
|
||||
var stamp time.Time
|
||||
if err := setupDB.QueryRow(query).Scan(&stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStamp(t, "owner setup clock", stamp, now)
|
||||
}
|
||||
wantStamp(t, "user created_at", createdAt(id), now)
|
||||
wantStamp(t, "verified_at", linkedAt(mc), now)
|
||||
wantStamp(t, "setup token created_at",
|
||||
stampAt(t, `SELECT created_at FROM setup_tokens WHERE token_hash = $1`, tok), now)
|
||||
wantStamp(t, "local auth updated_at",
|
||||
stampAt(t, `SELECT updated_at FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey), now)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+152
-15
@@ -1138,25 +1138,162 @@ func TestOwnerProvisioningWritesOwnerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The setup wizard's MC-bind path establishes THE Owner, so it writes the same
|
||||
// role as break-glass rather than a plain admin.
|
||||
func TestCompleteOwnerSetupWritesOwnerRole(t *testing.T) {
|
||||
// First-owner initialization needs an empty installation, rather than the
|
||||
// accounts left by other contract tests. Migrate an isolated schema using the
|
||||
// same database and real migration files.
|
||||
func setupRepository(t *testing.T) (*api.PGRepo, *sql.DB) {
|
||||
t.Helper()
|
||||
schema := "setup_" + suffix(t)
|
||||
mustExec(t, `CREATE SCHEMA `+schema)
|
||||
u, err := url.Parse(os.Getenv("FELIS_TEST_PG_URL"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
drv, err := store.Open(context.Background(), u.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { drv.Close(); mustExec(t, `DROP SCHEMA `+schema+` CASCADE`) })
|
||||
ms, err := store.LoadMigrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Up(context.Background(), drv, ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return api.NewPGRepo(drv.DB()), drv.DB()
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupContract(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow().Truncate(time.Second)
|
||||
id, username, err := r.CompleteOwnerSetup(ctx, "first-owner", now, "first-link", now.Add(time.Hour))
|
||||
if err != nil || id != "first-owner" || username != "owner" {
|
||||
t.Fatalf("setup = %q, %q, %v", id, username, err)
|
||||
}
|
||||
var role string
|
||||
if err := setupDB.QueryRow(`SELECT role FROM users WHERE id = $1`, id).Scan(&role); err != nil || role != "owner" {
|
||||
t.Fatalf("role = %q, %v", role, err)
|
||||
}
|
||||
linked, err := r.IsLinked(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if linked {
|
||||
t.Fatal("first panel login must not create a game binding")
|
||||
}
|
||||
// Deliberately fail token insertion. Auth/account state and the old link survive.
|
||||
if err := r.CreateSetupToken(ctx, "collision", id, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Reusing an existing token hash for a different user forces the insert to fail.
|
||||
_, err = setupDB.Exec(`INSERT INTO users (id, username, role) VALUES ('other-user', 'other', 'user')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = setupDB.Exec(`UPDATE setup_tokens SET user_id = 'other-user' WHERE token_hash = 'collision'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "collision", now.Add(time.Hour)); err == nil {
|
||||
t.Fatal("token collision accepted")
|
||||
}
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'first-link'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("old link lost after rollback: %d %v", count, err)
|
||||
}
|
||||
resumed, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "renewed-link", now.Add(time.Hour))
|
||||
if err != nil || resumed != id {
|
||||
t.Fatalf("resume = %q %v", resumed, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM users WHERE role = 'owner'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("duplicate owners: %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE user_id = $1`, id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("unused links not replaced: %d %v", count, err)
|
||||
}
|
||||
if _, err := setupDB.Exec(`UPDATE users SET email = '[email protected]', email_verified = true WHERE id = $1`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "must-not-exist", now.Add(time.Hour)); !errors.Is(err, api.ErrConflict) {
|
||||
t.Fatalf("settled account = %v", err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'renewed-link'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatal("settled account was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupSerializesFirstOwner(t *testing.T) {
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow()
|
||||
mc := testUUID(t)
|
||||
code := "osc-" + suffix(t)
|
||||
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(10*time.Minute)); err != nil {
|
||||
t.Fatalf("CreateLinkCode: %v", err)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, _, err := r.CompleteOwnerSetup(context.Background(), fmt.Sprintf("racer-%d", i), now, fmt.Sprintf("token-%d", i), now.Add(time.Hour))
|
||||
errs <- err
|
||||
}(i)
|
||||
}
|
||||
newID := "usr-setup-" + suffix(t)
|
||||
userID, gotUUID, src, err := repo.CompleteOwnerSetup(ctx, newID, code, now,
|
||||
"tok-"+suffix(t), now.Add(time.Hour))
|
||||
if err != nil || userID != newID || gotUUID != mc || src != "mojang" {
|
||||
t.Fatalf("CompleteOwnerSetup = (%s, %s, %s, %v), want (%s, %s, mojang, nil)",
|
||||
userID, gotUUID, src, err, newID, mc)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if role := userRole(t, newID); role != "owner" {
|
||||
t.Fatalf("CompleteOwnerSetup role = %q, want owner", role)
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM users WHERE role = 'owner'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("owners = %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("links = %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupCreationRollsBack(t *testing.T) {
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow()
|
||||
// Failing the final insert must undo the new Owner and
|
||||
// local-auth setting as well, not strand an account without a usable link.
|
||||
if _, err := setupDB.Exec(`ALTER TABLE setup_tokens ADD CONSTRAINT reject_setup_token CHECK (token_hash <> 'rejected')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := r.CompleteOwnerSetup(context.Background(), "rolled-back", now, "rejected", now.Add(time.Hour)); err == nil {
|
||||
t.Fatal("failed token insert accepted")
|
||||
}
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM users`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial Owner remained: %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial local-auth setting remained: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupPreservesPasskey(t *testing.T) {
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow()
|
||||
id, _, err := r.CompleteOwnerSetup(context.Background(), "owner-key", now, "first-link", now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setupDB.Exec(`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, name) VALUES ('key', $1, 'credential', 'public-key', 'Laptop')`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, name, err := r.CompleteOwnerSetup(context.Background(), "unused", now, "new-link", now.Add(time.Hour)); !errors.Is(err, api.ErrConflict) || got != id || name != "owner" {
|
||||
t.Fatalf("established passkey = %q %q %v", got, name, err)
|
||||
}
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("existing credential changed: %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'new-link'`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("setup created a reset link: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -371,6 +371,8 @@ func APIDeployment(p Params) *appsv1.Deployment {
|
||||
}
|
||||
env = append(env,
|
||||
corev1.EnvVar{Name: "FELIS_IMAGE", Value: p.FelisImage},
|
||||
// Passkey origins must include the same public port the Service exposes.
|
||||
corev1.EnvVar{Name: "FELIS_PANEL_NODEPORT", Value: fmt.Sprint(p.PanelNodePort)},
|
||||
// The api's own internal-face base URL, so it derives the submission
|
||||
// context URLs that build Pods fetch through it. Same value the login gate
|
||||
// is handed; one address for one face.
|
||||
|
||||
@@ -331,6 +331,15 @@ func TestAPIService_NodePort(t *testing.T) {
|
||||
p.PanelNodePort = 30445
|
||||
svc := apiService(p)
|
||||
dep := APIDeployment(p)
|
||||
foundPort := false
|
||||
for _, env := range dep.Spec.Template.Spec.Containers[0].Env {
|
||||
if env.Name == "FELIS_PANEL_NODEPORT" {
|
||||
foundPort = env.Value == "30445"
|
||||
}
|
||||
}
|
||||
if !foundPort {
|
||||
t.Fatal("API passkey origins do not know the Service's public NodePort")
|
||||
}
|
||||
|
||||
if svc.Name != SAAPI || svc.Namespace != p.ControlNamespace {
|
||||
t.Errorf("api Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, SAAPI)
|
||||
|
||||
Reference in new issue
Block a user